<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:media="http://search.yahoo.com/mrss/"><channel><title>PHP/Laravel 完全指南 - Bobo 的學思山丘</title><description>用 Laravel 12 打造台灣團購平台的完整旅程</description><link>https://bobochen.dev/</link><item><title>PHP/Laravel 完全指南：從這裡開始你自己的旅程</title><link>https://bobochen.dev/blog/laravel-guide-roadmap-next-steps/</link><guid isPermaLink="true">https://bobochen.dev/blog/laravel-guide-roadmap-next-steps/</guid><description>用 Laravel 12 打造揪好買團購平台的完整旅程到此告一段落。本章回顧全書 15 章的 Laravel 學習路徑，整理揪好買可擴展的功能方向，介紹 Nova、Vapor、Laracasts 等生態系資源，並為你規劃從初級到進階的 PHP／Laravel 成長路線圖。</description><pubDate>Tue, 10 Jun 2025 00:00:00 GMT</pubDate><content:encoded>十五章，從 PHP 基礎語法走到 Production 部署，你已經用 Laravel 12 從零打造了一個完整的團購平台。揪好買其實不只是個練習專案，它幾乎把現代 Web 應用開發的每個核心面向都摸過一遍：

- 路由與控制器、資料庫遷移與 Eloquent ORM
- 認證授權、表單驗證、檔案上傳
- 佇列任務、Event／Listener、Notification
- API 設計、後台管理、自動化測試
- 一直到部署上線

這些不是教科書上的範例，而是你在任何 Laravel 專案中都會用到的實戰技能。

但說實話，這本書能涵蓋的只是 Laravel 生態系的冰山一角。Laravel 之所以強大，框架本身設計得好只是一半，真正關鍵的是它背後有一整個生態系在支撐：Nova 給你企業級後台、Vapor 讓你跑 Serverless、Pennant 做 Feature Flag、Pulse 做即時效能監控。加上 Laracasts 這個可能是全世界最好的程式教學平台，你的學習資源幾乎是用不完的。

這最後一章，我們要做三件事：回顧這 15 章到底學了什麼、看看揪好買還能往哪些方向擴展、然後幫你畫一張接下來的學習路線圖。你已經有了穩固的基礎，接下來的路，你可以自己決定怎麼走。

## Laravel 完全指南回顧：15 章學到了什麼

先用一張表把整趟旅程看清楚：

| 章  | 主題               | 你學會的核心技能                                |
| --- | ------------------ | ----------------------------------------------- |
| 1   | PHP 快速入門       | PHP 8.4+ 型別系統、Enum、Match、Composer        |
| 2   | Laravel 起手式     | 安裝、目錄結構、Route、Blade、Artisan           |
| 3   | Request Lifecycle  | Service Container、DI、Middleware、Facade       |
| 4   | Eloquent ORM       | Migration、Model、Relationship、Factory         |
| 5   | Blade + Livewire   | Component、Livewire 即時互動、Alpine.js         |
| 6   | 認證與授權         | Starter Kit、Gate、Policy、Role Enum            |
| 7   | 表單驗證與檔案上傳 | Form Request、Validation Rules、Storage         |
| 8   | 跟團與成團邏輯     | Session、業務邏輯設計、Cache、狀態機            |
| 9   | 訂單與金流         | Stripe Cashier、Webhook、Database Transaction   |
| 10  | Queue 與 Event     | Job、Event/Listener、Notification、Mail         |
| 11  | RESTful API        | Sanctum Token、API Resource、Rate Limiting      |
| 12  | 後台管理           | Filament 4、N+1 優化、Query Scopes、Debugbar    |
| 13  | 測試               | Pest、HTTP Tests、Mock/Fake、GitHub Actions CI  |
| 14  | 部署               | Forge、Docker、Octane、Cloud Run、Zero-Downtime |
| 15  | 路線圖             | 你正在讀的這一章                                |

從語言基礎到 Production 部署，這是一條完整的學習路徑。你不再是「想學 Laravel 的人」，你已經是「用 Laravel 建過完整專案的開發者」了。

## 揪好買的完成功能清單

讓我們盤點一下揪好買目前有什麼：

**使用者系統**

- ✅ 註冊、登入、登出、忘記密碼
- ✅ Email 驗證
- ✅ 角色系統：一般會員 / 開團主 / 管理員

**團購功能**

- ✅ 開團（表單驗證 + 圖片上傳）
- ✅ 跟團（+1、選數量、即時人數更新）
- ✅ 成團判斷（最低人數 + 截止時間）
- ✅ 團購列表（即時搜尋、篩選、排序、分頁）

**金流與訂單**

- ✅ Stripe 串接（Checkout Session + Webhook）
- ✅ 訂單建立與狀態管理
- ✅ 成團後統一收款

**通知系統**

- ✅ 成團確認 Email
- ✅ 站內通知（database notification）
- ✅ 背景佇列處理

**API**

- ✅ RESTful API（團購列表、跟團、訂單查詢）
- ✅ Sanctum Token 認證
- ✅ Rate Limiting

**管理後台**

- ✅ Filament 管理員後台
- ✅ 開團主儀表板

**DevOps**

- ✅ Pest 測試套件
- ✅ GitHub Actions CI
- ✅ Docker 容器化
- ✅ Production 部署

這已經是一個具備核心功能的 MVP（最小可行產品）了。

## 功能擴展方向：搜尋、推薦、多語系

揪好買還有很多可以做的。以下是幾個值得考慮的方向：

### 全文搜尋

目前的搜尋用 `LIKE %keyword%`，資料量大時效能很差。升級方案：

- **Laravel Scout + Meilisearch**，全文搜尋引擎，支援中文斷詞、模糊搜尋、過濾排序
- `composer require laravel/scout` + `composer require meilisearch/meilisearch-php`
- Model 加上 `use Searchable;`，幾行設定就能讓搜尋體驗飛起來

### 推薦系統

「你可能也想跟的團」，根據使用者的跟團歷史推薦相似的團購：

- 簡單版：同品類的熱門團購（SQL 就能做）
- 進階版：協同過濾（Collaborative Filtering），可以用 Python microservice 處理

### 多語系（i18n）

讓揪好買支援繁中/英文切換：

- Laravel 內建 `resources/lang/` 翻譯檔
- `__(&apos;messages.welcome&apos;)` helper
- Middleware 偵測使用者語言偏好

### 即時通訊

開團主和跟團者之間的即時聊天：

- **Laravel Reverb**，Laravel 官方的 WebSocket 伺服器
- 搭配 Livewire 或 Echo（JavaScript）做即時更新
- 適合討論團購細節、配送安排

### 多租戶（Multi-tenancy）

讓不同公司/社區各自有獨立的揪好買實例：

- **stancl/tenancy** 套件
- 每個租戶有獨立的資料庫或共用資料庫加 tenant_id
- 適合 B2B SaaS 模式

## LINE 整合深化：從概念到實作

[第十一章](/blog/laravel-guide-api-sanctum-rest/)我們留了一個 LINE Bot 的概念性範例。如果要真正做起來：

### LINE Messaging API

```bash
composer require linecorp/line-bot-sdk
```

核心流程：

1. 在 LINE Developers Console 建立 Provider 和 Channel
2. 設定 Webhook URL 指向你的 Laravel API endpoint
3. 使用者在 LINE 群組裡輸入「!開團 辦公室零食箱」
4. 你的 webhook controller 解析指令、呼叫 GroupBuy service
5. 透過 LINE API 回覆結果

### LINE LIFF（LINE Frontend Framework）

更進一步，你可以在 LINE 裡嵌入 Web 頁面：

- 使用者在 LINE 裡直接打開揪好買的團購詳情頁
- 不需要跳轉到瀏覽器，體驗更流暢
- 搭配 Sanctum API 做認證

### LINE Pay

台灣使用者最常用的行動支付之一：

- 可以取代或補充 Stripe
- 需要另外串接 LINE Pay API（目前 Cashier 不支援，需自己整合）

## Laravel 生態系工具推薦

Laravel 的生態系大到你可能不知道從哪裡開始。以下是我認為**最值得認識**的工具：

### 開發工具

| 工具                   | 用途         | 一句話說明                                          |
| ---------------------- | ------------ | --------------------------------------------------- |
| **Laravel Herd**       | 本地開發環境 | 一鍵安裝 PHP + Nginx + 多版本切換（macOS/Windows）  |
| **Laravel Pint**       | 程式碼格式化 | PHP 的 Prettier，Laravel 12 內建                    |
| **Laravel IDE Helper** | IDE 支援     | 幫 PhpStorm/VS Code 理解 Facade 和 Model 的自動補全 |
| **Laravel Debugbar**   | 效能偵測     | [第 12 章](/blog/laravel-guide-admin-filament-advanced-queries/)用過，開發階段必裝                          |

### 官方套件

| 套件          | 用途                 | 何時需要                           |
| ------------- | -------------------- | ---------------------------------- |
| **Sanctum**   | API Token 認證       | 你已經會了（[第 11 章](/blog/laravel-guide-api-sanctum-rest/)）             |
| **Cashier**   | 金流整合             | 你已經會了（[第 9 章](/blog/laravel-guide-orders-stripe-cashier/)）              |
| **Scout**     | 全文搜尋             | 搜尋功能需要升級時                 |
| **Horizon**   | Queue 監控 Dashboard | Redis Queue 在 production 跑的時候 |
| **Telescope** | Debug Dashboard      | 開發階段觀察 request、query、job   |
| **Reverb**    | WebSocket 伺服器     | 即時通訊、即時通知                 |
| **Pennant**   | Feature Flags        | A/B 測試、漸進式上線新功能         |
| **Pulse**     | 即時效能監控         | Production 觀察應用健康狀態        |

## Nova、Vapor、Pennant、Pulse 簡介

這四個是 Laravel 官方的商業產品（需要付費授權），適合有預算的團隊：

### Laravel Nova（一次性授權，$99 / $199 / $299 per site）

企業級後台管理面板。比 Filament 功能更完整，但需要付費：

- 更多內建欄位類型和 Action
- Metrics Dashboard 更豐富
- 權限管理更細緻
- 適合：中大型團隊、有預算、需要企業級後台

### Laravel Vapor（$39/mo 起，另計 AWS 費用）

Serverless 部署平台，底層跑在 AWS Lambda：

- 自動擴縮容（Auto-scaling），流量大時自動加機器
- 不用管伺服器、不用管 Nginx
- 適合：流量波動大（例如團購開團瞬間流量暴增）、有 AWS 預算的團隊

### Laravel Pennant

Feature Flag 管理：

- 控制功能對哪些使用者可見（例如：先讓 10% 使用者看到新 UI）
- A/B 測試
- 漸進式上線，降低風險

### Laravel Pulse

即時應用程式效能監控：

- 顯示 slow queries、slow requests、exceptions
- Queue 和 Cache 使用狀態
- 比 Debugbar 更適合 production 使用

## 社群與學習資源

### Laracasts

如果你只能訂閱一個學習平台，選 Laracasts。Jeffrey Way 的教學品質是業界標竿，涵蓋 Laravel、PHP、Vue.js、Testing 等主題。大量免費內容可以先看看合不合口味。

### Laravel News

Laravel 生態系的新聞中心，每天更新套件推薦、教學文章、版本發布。訂閱 Newsletter 就能掌握最新動態。

### Laravel Daily

Povilas Korop 經營的 YouTube 頻道和部落格，專注實戰技巧和最佳實踐。影片短而精準，適合通勤時看。

### 中文社群

- **Laravel 台灣** Facebook 社團，台灣最活躍的 Laravel 中文社群
- **LearnKu Laravel 中國**，簡體中文，但很多文章品質很高
- **PHP 也有 Day** 社群，台灣 PHP 開發者聚會

### 推薦書籍

- _Laravel Up &amp; Running_（Matt Stauffer），最完整的 Laravel 參考書
- _PHP: The Right Way_，免費線上書，現代 PHP 最佳實踐
- _Refactoring to Collections_（Adam Wathan），用 Collection 取代迴圈，提升程式碼品質

## PHP 生態現況與未來展望

2026 年的 PHP 生態比以往任何時候都更健康：

- **PHP 8.5** 已穩定發布，pipe operator（`|&gt;`）、原生 URI 擴充、clone with 屬性覆寫讓語法更精煉（Property Hooks 和 Asymmetric Visibility 是 PHP 8.4 引進的）
- **Laravel 拿到 $57M 融資**，代表商業生態系在成長
- **Packagist** 超過 45 萬個套件，生態系穩定且持續壯大
- **效能持續改善**，JIT 編譯器每個版本都在進步，搭配 Octane 更是翻倍
- **WordPress 依然佔全球 42.6% 的網站**，PHP 的市場不會消失

PHP 不是最潮的語言，也不需要是。它是最務實的選擇之一。

### 值得關注的趨勢

- **Laravel Cloud**，Laravel 官方的全託管部署平台，已於 2025 年 2 月隨 Laravel 12 正式上線（Sandbox $0／Production $20/mo／Business $200/mo 起，另計用量）
- **PHP 原生非同步**，Fibers 和 Revolt event loop 讓 PHP 能處理高並發場景
- **AI 整合**，Laravel Prompts、OpenAI 套件，PHP 也能做 AI 應用

## 小結：從這裡開始你自己的旅程

十五章、一個完整的團購平台、從 PHP 語法到 Production 部署。你已經走過了 Laravel 的完整學習路徑。

但更重要的是，這趟下來你學到的其實不只 Laravel。你還學到了：

- **框架思維**，不重複造輪子，善用生態系
- **分層架構**，Route → Controller → Service → Model，各司其職
- **測試文化**，有測試的程式碼才有信心重構和部署
- **DevOps 基礎**，CI/CD、容器化、環境管理

這些技能是通用的。不管你將來用 Laravel、Rails、Django 還是 NestJS，底層的思維方式是一樣的。

### 你的下一步

根據你的方向，我推薦的學習路線：

**想深入 Laravel？**
→ Laracasts 進階課程 → Laravel Horizon → Laravel Reverb → 讀 Laravel 原始碼

**想做自己的 SaaS？**
→ 加入 Stripe 訂閱制（Cashier 支援）→ Multi-tenancy → Vapor 部署

**想找 Laravel 相關工作？**
→ GitHub Profile 放上揪好買專案 → 寫技術部落格分享學習心得 → 加入 Laravel 台灣社群

**想把揪好買上線？**
→ 加入 LINE 整合 → 接 LINE Pay → 找幾個朋友當 Beta 使用者 → 真的拿去開團試試看

不管你選哪條路，記住一件事：**最好的學習方式就是持續建造**。不要只看教學，要動手寫。寫出 Bug，修掉它。看到新套件，裝上去試試。遇到問題，去社群問。

揪好買是你的起點，不是終點。

祝你寫程式愉快。🚀</content:encoded><media:content url="https://bobochen.dev/_astro/cover.BGaLi2VY.webp" medium="image"/><category>PHP</category><category>Laravel</category><category>學習路線圖</category><category>Laravel Ecosystem</category><enclosure url="https://bobochen.dev/_astro/cover.BGaLi2VY.webp" length="0" type="image/png"/></item><item><title>部署上線：從 Laravel Forge 到容器化的三條路</title><link>https://bobochen.dev/blog/laravel-guide-deployment-forge-docker/</link><guid isPermaLink="true">https://bobochen.dev/blog/laravel-guide-deployment-forge-docker/</guid><description>Laravel 12 應用部署上線完整指南：比較 Laravel Forge 一鍵部署、Docker 容器化、與 Cloud Run / Fly.io Serverless 三條路線，涵蓋 Production 設定、Config/Route Cache、Laravel Octane 加速、Let&apos;s Encrypt SSL 與零停機部署實作。</description><pubDate>Tue, 03 Jun 2025 00:00:00 GMT</pubDate><content:encoded>程式寫好了、測試通過了、功能也確認沒問題了。然後呢？「部署」這件事聽起來很簡單——把程式碼放到伺服器上就好了嘛——但實際操作起來，從環境設定、效能調校、SSL 憑證、到零停機部署，每一步都有它的眉角。選錯部署方式，你可能花更多時間在維運上，而不是開發新功能。

Laravel 生態系提供了三條截然不同的部署路線：Laravel Forge 讓你在 DigitalOcean 或 AWS 上一鍵部署，幾乎不用碰伺服器設定；Docker 容器化讓你的應用在任何環境都能一致運行；Cloud Run 和 Fly.io 這類雲端平台則讓你連伺服器都不用管。三條路各有優缺點，適合不同階段和不同規模的團隊。

這一章我們會走過上線前的必要準備——Config Cache、Route Cache、Octane 加速——然後實際帶你用兩種方式部署揪好買：Forge 的一鍵流程和 Docker 的容器化流程。不管你最後選哪條路，上線前該做的事情都一樣，而這些知識會讓你省下無數個半夜被叫起來修 Bug 的夜晚。

## Laravel 部署上線前的 Production 設定

不管你選哪條部署路線，上線前有一份 checklist 是每個 Laravel 專案都必須走過的。這些設定漏掉任何一項，輕則效能低落，重則資料外洩。

### 環境變數核心設定

打開你的 `.env`（或部署平台的環境變數設定），確認這三項：

```bash
APP_ENV=production
APP_DEBUG=false
APP_KEY=base64:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

`APP_ENV=production` 告訴 Laravel 現在是正式環境，會影響一些行為——例如錯誤頁面不會顯示堆疊追蹤、某些開發工具會被停用。`APP_DEBUG=false` **絕對不能忘記**——如果設成 `true`，使用者在瀏覽器裡就能看到你的完整錯誤訊息，包含資料庫密碼、API Key、程式碼路徑，等於把系統的所有弱點攤在陽光下。

`APP_KEY` 是 Laravel 用來加密 Session、Cookie、Eloquent 加密欄位的密鑰。如果你在部署時沒有設定，所有加密功能都會失效。在新環境第一次部署時執行：

```bash
php artisan key:generate
```

&gt; **警告：** 正式環境的 `APP_KEY` 一旦設定就不要隨意更換。換了之後，所有舊的加密資料（包含使用者的 Session）都會失效，等同於強制所有使用者重新登入。

### 上線前完整 Checklist

```bash
# 1. 環境設定
APP_ENV=production
APP_DEBUG=false
APP_KEY=base64:... (已設定)

# 2. 資料庫
DB_CONNECTION=mysql          # 或 pgsql
DB_HOST=your-production-db   # 不要用 127.0.0.1，除非 DB 在同一台機器
DB_DATABASE=jiuhaobuy
DB_USERNAME=jiuhaobuy_user   # 不要用 root
DB_PASSWORD=strong-password   # 至少 20 字元

# 3. Cache &amp; Session
CACHE_STORE=redis            # 正式環境用 Redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis

# 4. Mail（正式環境不要用 log）
MAIL_MAILER=smtp
MAIL_HOST=smtp.mailgun.org   # 或 SES、Postmark

# 5. URL
APP_URL=https://jiuhaobuy.com
```

### 執行 Migration

在正式環境執行 migration 時，加上 `--force` flag——因為 Laravel 在 production 環境會要求確認：

```bash
php artisan migrate --force
```

**最佳做法：** 不要直接 SSH 進伺服器手動跑 migration。把它放進你的部署腳本（deploy script），讓它在每次部署時自動執行。Forge 和 Docker 都支援這種做法，後面會示範。

### Queue Worker 設定

[上一章](/blog/laravel-guide-queues-events-notifications/)我們用 `php artisan queue:work` 在本地跑 Queue Worker。正式環境你需要一個 process manager 來確保 worker 持續運行。最常用的是 Supervisor：

```ini
# /etc/supervisor/conf.d/jiuhaobuy-worker.conf
[program:jiuhaobuy-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/jiuhaobuy/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/jiuhaobuy/storage/logs/worker.log
```

### Scheduler 設定（Cron）

Laravel 的 Task Scheduler 需要一個 cron job 每分鐘觸發一次。在伺服器上執行 `crontab -e`，加上這一行：

```bash
* * * * * cd /var/www/jiuhaobuy &amp;&amp; php artisan schedule:run &gt;&gt; /dev/null 2&gt;&amp;1
```

這一行 cron 會每分鐘執行 `schedule:run`，然後由 Laravel 內部判斷哪些排程任務該在這一分鐘執行。[上一章](/blog/laravel-guide-queues-events-notifications/)設定的截止提醒通知、定時清理過期團購，都靠這一行 cron 來驅動。

## Config / Route / View Caching：榨乾每一滴效能

Laravel 在每次 request 進來時，預設會重新讀取所有 config 檔案、解析所有路由、編譯 Blade 模板。這在開發時很方便——改了 config 馬上生效——但在正式環境，這些重複的 I/O 和解析是純粹的浪費。

### Config Cache

```bash
php artisan config:cache
```

這個指令把 `config/` 目錄下所有的設定檔合併成一個 PHP 檔案 `bootstrap/cache/config.php`。Laravel 載入一個檔案的速度遠快於掃描整個目錄。效果有多好？在有 30 個 config 檔案的專案裡，這一個指令就能減少 request bootstrap 時間約 40-60%。

**注意事項：** 一旦 cache 了 config，`env()` function 只能在 config 檔案裡使用。如果你在 Controller 或 Model 裡直接呼叫 `env(&apos;SOME_VAR&apos;)`，它會回傳 `null`。正確做法是永遠透過 `config(&apos;services.some_var&apos;)` 來取值，而不是直接呼叫 `env()`。

### Route Cache

```bash
php artisan route:cache
```

把所有路由編譯成序列化的 PHP 陣列。Laravel 載入預編譯的路由比每次解析 `routes/web.php` 和 `routes/api.php` 快非常多——路由越多效果越明顯。有上百條路由的專案，啟動時間可以減少 50% 以上。

### View Cache

```bash
php artisan view:cache
```

預先編譯所有 Blade 模板成 PHP 檔案，存放在 `storage/framework/views/`。正式環境不該讓第一個使用者承受模板編譯的延遲。

### Event Cache

```bash
php artisan event:cache
```

把 Event 和 Listener 的對應關係快取起來，省去每次 request 掃描和自動發現的開銷。

### 一鍵全部搞定

Laravel 提供了一個 `optimize` 指令，做上面所有快取的事：

```bash
php artisan optimize
```

這等同於執行 `config:cache` + `route:cache` + `view:cache` + `event:cache`。部署腳本裡通常就放這一行。

開發時如果要清除所有快取：

```bash
php artisan optimize:clear
```

### Before / After 比較

以「揪好買」的實際測量為例，在一台 1 vCPU / 1GB RAM 的伺服器上：

| 指標 | 未快取 | 快取後 | 改善 |
|------|--------|--------|------|
| 首次 request 回應時間 | ~180ms | ~45ms | **75%** |
| Config 載入時間 | ~12ms | ~1ms | 92% |
| Route 解析時間 | ~8ms | ~1ms | 88% |
| 記憶體使用 | ~32MB | ~24MB | 25% |

這些數字告訴你：**上線前跑 `php artisan optimize` 不是選配，是必做。**

## 路線一：Laravel Forge 一鍵部署

如果你不想花時間在伺服器管理上——安裝 PHP、設定 Nginx、調整防火牆、更新系統套件——Laravel Forge 就是你的答案。它是 Laravel 官方團隊提供的伺服器管理服務。定價分為兩個主要方案：$12/月的 Hobby 方案支援無限台 Laravel 自家 VPS，但只允許連接 1 台外部主機商（DigitalOcean、AWS、Hetzner 等）的伺服器；若要連接無限台外部雲端主機商伺服器，需升級至 $19/月的 Growth 方案。對「揪好買」這種單機小專案，Hobby 方案已經足夠。

### Forge 是什麼

Forge 不是主機商——它不賣伺服器。它是一個管理層：你提供 DigitalOcean、AWS、Hetzner、Vultr 等主機商的 API Key，Forge 幫你自動建立（provision）伺服器，然後持續管理它。Forge 幫你做的事包括：

- 安裝 PHP（支援多版本切換）、Nginx、MySQL/PostgreSQL、Redis
- 設定防火牆規則、SSH Key 管理
- 自動化部署：連結 GitHub repo，Push 到 main 就自動部署
- SSL 憑證：Let&apos;s Encrypt 一鍵設定、自動續約
- 排程任務和 Queue Worker 管理
- 資料庫備份

### 部署流程

**第一步：註冊並連結主機商**

在 [forge.laravel.com](https://forge.laravel.com) 註冊帳號。到 Server Providers 頁面，把你的 DigitalOcean（或其他主機商）API Token 填進去。

**第二步：建立伺服器**

點「Create Server」，選擇主機商、地區、規格。以「揪好買」來說，一台 DigitalOcean $6/月的 Droplet（1 vCPU / 1GB RAM）就足以應付初期流量。Forge 會花大約 10 分鐘自動安裝所有需要的軟體。

**第三步：連結 GitHub Repo**

在 Forge 的 Sites 區塊點「New Site」，填入你的 domain name（例如 `jiuhaobuy.com`），然後連結 GitHub repository。Forge 會在伺服器上 clone 你的程式碼。

**第四步：設定 Deploy Script**

Forge 預設的 deploy script 大概長這樣：

```bash
cd /home/forge/jiuhaobuy.com
git pull origin $FORGE_SITE_BRANCH

composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader

php artisan migrate --force
php artisan optimize

# 如果你用了 npm assets
npm ci
npm run build

php artisan queue:restart
```

每次你 Push 到 GitHub，Forge 就會自動執行這段腳本。

**第五步：SSL 憑證**

在 Forge 的 SSL 區塊，點「Let&apos;s Encrypt」，填入你的 domain，點確認。Forge 會自動申請、設定、並且在到期前自動續約。整個過程不到一分鐘。

### Forge 的優缺點

| 優點 | 缺點 |
|------|------|
| 幾乎零學習曲線，UI 操作 | 月費 $12（加上主機費用） |
| 自動化部署、SSL、備份 | 你的伺服器管理依賴 Forge |
| 適合個人開發者和小團隊 | 不適合需要複雜基礎設施的場景 |
| 官方維護，與 Laravel 整合最好 | 如果哪天想離開，需要自己接手伺服器管理 |

對於「揪好買」這種小型到中型的專案，Forge 是最快上線的方式。你可以把省下來的時間拿去做功能開發，而不是除錯 Nginx 設定。

## 路線二：Docker 容器化部署

Docker 解決的核心問題是：**「在我電腦上可以跑」和「在伺服器上可以跑」不再是兩件事。** 你把應用程式和它的所有依賴（PHP 版本、擴充套件、系統套件）打包成一個 image，這個 image 在任何有 Docker 的機器上都能一致地運行。

### 為什麼要用 Docker

- **環境一致性**——開發、CI、正式環境用同一個 image，不會有「PHP 版本不對」的問題
- **可攜性**——image 可以跑在 AWS、GCP、Azure，或你辦公室的 NAS 上
- **CI/CD 友善**——Build image → Push to registry → Deploy，流程標準化
- **隔離性**——每個服務跑在自己的 container，互不干擾

### Dockerfile：多階段建置

多階段建置（multi-stage build）讓你的 production image 只包含必要的檔案，不會把 Composer、npm、開發工具帶進去：

```dockerfile
# ===== 第一階段：安裝 PHP 依賴 =====
FROM composer:2 AS composer-build
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
COPY . .
RUN composer dump-autoload --optimize

# ===== 第二階段：建置前端資源 =====
FROM node:24-alpine AS node-build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# ===== 第三階段：Production Image =====
FROM php:8.4-fpm-alpine

# 安裝系統依賴和 PHP 擴充
RUN apk add --no-cache \
    nginx \
    supervisor \
    libpng-dev \
    libzip-dev \
    icu-dev \
    &amp;&amp; docker-php-ext-install \
    pdo_mysql \
    gd \
    zip \
    intl \
    opcache \
    pcntl

# 複製 Nginx 設定
COPY docker/nginx.conf /etc/nginx/http.d/default.conf

# 複製 Supervisor 設定
COPY docker/supervisord.conf /etc/supervisord.conf

# 複製應用程式碼
WORKDIR /var/www/html
COPY --from=composer-build /app/vendor ./vendor
COPY --from=node-build /app/public/build ./public/build
COPY . .

# 設定權限
RUN chown -R www-data:www-data storage bootstrap/cache \
    &amp;&amp; chmod -R 775 storage bootstrap/cache

# OPcache 設定
COPY docker/opcache.ini /usr/local/etc/php/conf.d/opcache.ini

EXPOSE 80

CMD [&quot;/usr/bin/supervisord&quot;, &quot;-c&quot;, &quot;/etc/supervisord.conf&quot;]
```

### docker-compose.yml

開發和測試時，用 `docker-compose` 把所有服務串起來：

```yaml
# docker-compose.yml
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - &quot;8080:80&quot;
    environment:
      - APP_ENV=production
      - APP_DEBUG=false
      - DB_HOST=mysql
      - REDIS_HOST=redis
    env_file:
      - .env
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_started
    volumes:
      - storage:/var/www/html/storage

  mysql:
    image: mysql:8.4
    environment:
      MYSQL_DATABASE: jiuhaobuy
      MYSQL_USER: jiuhaobuy_user
      MYSQL_PASSWORD: secret
      MYSQL_ROOT_PASSWORD: root_secret
    volumes:
      - mysql-data:/var/lib/mysql
    healthcheck:
      test: [&quot;CMD&quot;, &quot;mysqladmin&quot;, &quot;ping&quot;, &quot;-h&quot;, &quot;localhost&quot;]
      interval: 5s
      timeout: 3s
      retries: 5

  redis:
    image: redis:7-alpine
    volumes:
      - redis-data:/data

  queue-worker:
    build:
      context: .
      dockerfile: Dockerfile
    command: php artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
    env_file:
      - .env
    environment:
      - DB_HOST=mysql
      - REDIS_HOST=redis
    depends_on:
      - app

  scheduler:
    build:
      context: .
      dockerfile: Dockerfile
    command: sh -c &quot;while true; do php artisan schedule:run --verbose --no-interaction &amp; sleep 60; done&quot;
    env_file:
      - .env
    environment:
      - DB_HOST=mysql
      - REDIS_HOST=redis
    depends_on:
      - app

volumes:
  mysql-data:
  redis-data:
  storage:
```

### 建置與執行

```bash
# 建置 image
docker compose build

# 啟動所有服務
docker compose up -d

# 第一次啟動：跑 migration 和 cache
docker compose exec app php artisan migrate --force
docker compose exec app php artisan optimize

# 查看 logs
docker compose logs -f app

# 停止所有服務
docker compose down
```

### 推送到 Container Registry

正式部署時，你會把 image 推到 container registry（Docker Hub、GitHub Container Registry、Google Artifact Registry 等），然後在伺服器或雲端平台上拉取這個 image：

```bash
# 建置並標記 image
docker build -t ghcr.io/your-org/jiuhaobuy:latest .
docker build -t ghcr.io/your-org/jiuhaobuy:v1.0.0 .

# 推送到 GitHub Container Registry
docker push ghcr.io/your-org/jiuhaobuy:latest
docker push ghcr.io/your-org/jiuhaobuy:v1.0.0
```

&gt; **建議：** 除了 `latest` tag，永遠也打一個版本號 tag（例如 `v1.0.0` 或 Git commit SHA）。這樣你可以隨時回滾到指定版本。

## Laravel Octane：Swoole / FrankenPHP 加速

傳統的 PHP 執行模式是：每個 request 進來 → 載入框架 → 處理 request → 回應 → 丟掉所有東西。下一個 request 再從頭來一次。這就像每次客人進餐廳，你都要重新蓋一次廚房、擺一次桌椅、然後煮完菜再把整間餐廳拆掉。

Laravel Octane 改變了這個模式：它在第一次啟動時載入整個框架到記憶體裡，之後每個 request 都是在已經準備好的環境中處理，不需要重新 bootstrap。

### FrankenPHP vs Swoole

Octane 支援三種 server：

| Server | 特點 | 適合場景 |
|--------|------|---------|
| **FrankenPHP** | Go 實作，簡單易用，支援 HTTP/3 | **推薦首選**，設定最少 |
| **Swoole** | C 擴充，功能最多（WebSocket、協程） | 需要 WebSocket 或高併發場景 |
| **RoadRunner** | Go 實作，穩定成熟 | 需要長期穩定的場景 |

對大多數 Laravel 應用來說，**FrankenPHP 是目前最推薦的選擇**——它是 Caddy web server 的一部分，安裝簡單，而且自帶 HTTPS 支援（Caddy 的 automatic HTTPS）。

### 安裝與設定

```bash
composer require laravel/octane

php artisan octane:install
# 選擇 frankenphp
```

啟動 Octane：

```bash
# 開發環境（帶 watch 模式，改檔案自動重啟）
php artisan octane:start --watch

# 正式環境
php artisan octane:start --host=0.0.0.0 --port=8000 --workers=4
```

### 效能提升

在「揪好買」的實測環境（同一台 1 vCPU 機器），使用 Apache Bench 測試 `/api/group-buys` endpoint：

| 指標 | PHP-FPM | Octane (FrankenPHP) | 提升幅度 |
|------|---------|---------------------|---------|
| Requests/sec | ~120 | ~450 | **3.7x** |
| 平均回應時間 | ~83ms | ~22ms | 3.8x |
| P99 回應時間 | ~250ms | ~65ms | 3.8x |
| 記憶體使用 | 每 request 獨立 | 共享 ~80MB | 整體更省 |

吞吐量提升 3-5 倍是常見的數字。對於流量還小的新專案，你可能感覺不到差異；但當流量成長到一定程度，Octane 可以讓你晚幾個月才需要升級伺服器規格。

### 注意事項：記憶體和靜態狀態

Octane 把應用保持在記憶體裡，這帶來一個重要的副作用：**靜態變數和全域狀態會跨 request 存活。**

```php
// ❌ 危險：這個計數器會在所有 request 之間共享
class SomeService
{
    private static int $count = 0;

    public function handle(): void
    {
        self::$count++; // 每個 request 都會累加
    }
}

// ✅ 正確：用 request-scoped 的方式
class SomeService
{
    public function handle(Request $request): void
    {
        // 從 request 或 container 取值，不用靜態狀態
    }
}
```

Laravel 12 的核心服務都已經處理好 Octane 相容性。但如果你用了第三方套件，需要確認它們是否「Octane-friendly」。常見的陷阱：

- **不要在 Service 裡存 request 相關的狀態**——每個 worker 會處理多個 request
- **不要在 singleton 服務裡快取 per-request 資料**——它不會在下一個 request 被清除
- **資料庫連線可能因為太久沒用而斷開**——Octane 有內建的連線池管理，但要注意超時設定

## 路線三：Cloud Run / Fly.io 雲端部署

如果你不想管伺服器，也不想管伺服器上的作業系統更新和安全修補——那就讓雲端平台幫你管。

### Google Cloud Run

Cloud Run 的概念很單純：你給它一個 Docker image，它幫你跑起來。沒有流量的時候自動縮到零、有流量的時候自動擴展、SSL 自動搞定、domain mapping 一行指令。而且計費是 **per-request**——真的沒人用的時候，你一毛錢都不用付。

部署揪好買到 Cloud Run：

```bash
# 建置 image 並推送到 Google Artifact Registry
gcloud builds submit --tag asia-east1-docker.pkg.dev/your-project/jiuhaobuy/app:latest

# 部署到 Cloud Run
gcloud run deploy jiuhaobuy \
  --image asia-east1-docker.pkg.dev/your-project/jiuhaobuy/app:latest \
  --platform managed \
  --region asia-east1 \
  --allow-unauthenticated \
  --set-env-vars APP_ENV=production,APP_DEBUG=false \
  --set-secrets APP_KEY=app-key:latest,DB_PASSWORD=db-password:latest \
  --min-instances 0 \
  --max-instances 10
```

Cloud Run 的限制：它是 stateless 的，container 隨時可能被回收。這意味著：

- **Session 和 Cache 不能用 file driver**——必須用 Redis 或 database
- **Queue Worker 需要另外跑**——可以用 Cloud Run Jobs 或另一個 always-on 的 Cloud Run service
- **Scheduler 要用 Cloud Scheduler 觸發**——設定一個每分鐘打一次 `/schedule` endpoint 的 cron job

### Laravel Cloud（官方第一方 serverless）

如果要談「serverless 部署」，2026 年最對口的官方答案其實是 [Laravel Cloud](https://cloud.laravel.com)——Laravel 官方在 2025-02-24 隨 Laravel 12 一起推出的全託管部署平台，由 Laravel 團隊自己營運。它和 Laravel 整合最深：`git push` 就部署，內建 usage-based 計費與 scale-to-zero（閒置時自動縮到零、只付實際運算時間），佇列、排程、資料庫都幫你託管好，省去自己拼湊 Cloud Run Jobs + Cloud Scheduler 的工。

實務上的取捨：Laravel Cloud 上手最快、最不用碰基礎設施，適合想專心寫 Laravel、不想管 DevOps 的團隊；Cloud Run / Fly.io 則給你更多底層掌控與跨雲彈性。如果你已經在 GCP/AWS 生態系裡，Cloud Run 仍是整合最順的選擇；但只要是 Laravel 專案要找 serverless，Laravel Cloud 都值得先列入評估。

### Fly.io

Fly.io 的思路和 Cloud Run 類似，但它提供了 persistent volumes（持久化儲存），對需要本地檔案儲存的應用更友善。部署同樣基於 Docker image：

```bash
# 安裝 Fly CLI
curl -L https://fly.io/install.sh | sh

# 初始化專案
fly launch

# 部署
fly deploy
```

Fly.io 的 `fly.toml` 設定檔：

```toml
[build]
  dockerfile = &quot;Dockerfile&quot;

[env]
  APP_ENV = &quot;production&quot;
  APP_DEBUG = &quot;false&quot;

[http_service]
  internal_port = 8080
  force_https = true
  auto_stop_machines = true
  auto_start_machines = true
  min_machines_running = 0

[[vm]]
  size = &quot;shared-cpu-1x&quot;
  memory = &quot;512mb&quot;
```

### 三條路線比較

| 面向 | Forge | Docker + VPS | Cloud Run / Fly.io |
|------|-------|-------------|-------------------|
| 學習曲線 | 低 | 中 | 中 |
| 初始設定時間 | 30 分鐘 | 2-4 小時 | 1-2 小時 |
| 月費（小型專案） | ~$18 (Forge $12 + VPS $6) | ~$6 (VPS only) | ~$0-5 (pay-per-use) |
| 伺服器管理 | Forge 代管 | 自己管 | 雲端平台代管 |
| 擴展性 | 手動加機器 | 手動或用 K8s | 自動擴展 |
| 自訂程度 | 中 | 高 | 受平台限制 |
| CI/CD 整合 | Git push 自動部署 | 需自己設定 pipeline | Docker image + 一行指令 |
| 適合對象 | 個人/小團隊 | 有 DevOps 經驗的團隊 | Serverless 愛好者 |

### 成本比較（月流量 10 萬 PV）

| 項目 | Forge + DO | Docker + VPS | Cloud Run |
|------|-----------|-------------|-----------|
| 主機/運算 | $12 (2 vCPU) | $12 (2 vCPU) | ~$8 |
| 管理服務 | $12 (Forge) | $0 | $0 |
| 資料庫 | 含在 VPS | 含在 VPS | ~$10 (Cloud SQL) |
| Redis | 含在 VPS | 含在 VPS | ~$6 (Memorystore) |
| **合計** | **~$24/月** | **~$12/月** | **~$24/月** |

結論：小專案的話，三條路成本差不多。Docker + VPS 最便宜但你要自己管；Forge 和 Cloud Run 花錢買時間。

## SSL / HTTPS 設定

2026 年了，HTTPS 不是選配——瀏覽器會把 HTTP 網站標記為「不安全」，SEO 也會被扣分。好消息是，免費的 SSL 憑證現在垂手可得。

### Let&apos;s Encrypt（免費、自動續約）

Let&apos;s Encrypt 提供免費的 SSL 憑證，有效期 90 天，支援自動續約。在不同部署方式下的設定方式：

**Forge：** 前面提過，在 SSL 區塊點「Let&apos;s Encrypt」就搞定了。Forge 會自動設定 Nginx、自動續約。零操作。

**Docker + Caddy：** 如果你用 Caddy 取代 Nginx 當反向代理，HTTPS 是全自動的——Caddy 預設就會幫你申請和續約 Let&apos;s Encrypt 憑證：

```caddyfile
# Caddyfile
jiuhaobuy.com {
    reverse_proxy app:8080
}
```

就這樣。不需要任何額外設定。這也是為什麼越來越多人在 Docker 部署時選擇 Caddy 而不是 Nginx。

**Docker + Nginx + Certbot：** 如果你堅持用 Nginx，需要搭配 Certbot：

```bash
# 安裝 Certbot
apt install certbot python3-certbot-nginx

# 申請憑證
certbot --nginx -d jiuhaobuy.com -d www.jiuhaobuy.com

# 自動續約（Certbot 會自動設定 cron）
certbot renew --dry-run
```

**Cloud Run / Fly.io：** 自動提供 HTTPS，不需要任何設定。

### Laravel 的 HTTPS 設定

在 `AppServiceProvider` 裡強制所有 URL 使用 HTTPS：

```php
public function boot(): void
{
    if ($this-&gt;app-&gt;environment(&apos;production&apos;)) {
        URL::forceScheme(&apos;https&apos;);
    }
}
```

同時在 `.env` 確認 `APP_URL` 使用 `https://`：

```bash
APP_URL=https://jiuhaobuy.com
```

## Zero-Downtime 部署策略

使用者正在瀏覽你的網站，你按下部署按鈕，程式碼更新過程中——使用者看到一個 500 錯誤。這就是「有停機時間的部署」的問題。對一個團購平台來說，如果在結單的關鍵時刻出現錯誤，直接就是商譽和金錢的損失。

### 問題出在哪

傳統的 `git pull` + `composer install` 部署方式，中間有一段過渡期：舊的程式碼已經被覆蓋了，但新的依賴還沒裝好、cache 還沒更新。這段期間的 request 就會出錯。

### Atomic Symlink 部署（Forge / Envoyer）

原理：每次部署建立一個新的完整目錄（例如 `releases/20260831120000/`），在新目錄裡把所有東西準備好——Composer install、npm build、migration、cache——然後用一個 symlink 切換，把 `current/` 指向新的 release 目錄。Symlink 的切換是原子操作（atomic），不會有中間狀態。

```text
/var/www/jiuhaobuy/
├── releases/
│   ├── 20260830_120000/   ← 上一版
│   └── 20260831_120000/   ← 新版（已經準備好了）
├── current -&gt; releases/20260831_120000/   ← symlink 一切換，馬上生效
├── storage/               ← 所有 release 共用
└── .env                   ← 所有 release 共用
```

Laravel Forge 內建就支援這種部署模式。2026 年所有新的 Forge 訂閱（含 $12 Hobby 方案）都已內建單機 zero-downtime / atomic 部署，官方明確說明「不需要再額外訂閱 Envoyer」。[Laravel Envoyer](https://envoyer.io)（$12/月）現在的價值在於「把同一個專案部署到多台伺服器」這種多機情境——如果你只是單機部署，Forge 本身就夠用，不必為了 zero-downtime 再多付這筆錢。

### Docker Rolling Update

Docker 環境的零停機部署靠的是 rolling update：先啟動新版 container，確認健康檢查通過後，再停止舊版 container。

如果用 Docker Compose：

```bash
# 建置新 image
docker compose build app

# 滾動更新（先起新的，再停舊的）
docker compose up -d --no-deps --build app
```

在 Cloud Run 或 Kubernetes 環境，rolling update 是預設行為——你部署新版本後，平台會自動漸進式地把流量切換到新的 container。

### Blue-Green 部署

更進階的做法：同時維護兩套完全相同的環境（Blue 和 Green）。目前 Blue 在服務使用者，你把新版本部署到 Green，測試通過後，把 load balancer 切換到 Green。如果新版本有問題，馬上切回 Blue——回滾時間幾乎是零。

這種做法的缺點是成本比較高（你要維護兩套環境），所以通常是有一定規模的團隊才會採用。「揪好買」初期用 Forge 的 atomic symlink 或 Docker 的 rolling update 就綽綽有餘。

## 環境變數管理：Production 的 .env

`.env` 檔案裡有你的資料庫密碼、API Key、加密金鑰——這些東西如果外洩，後果不堪設想。管理 production 環境變數有幾個鐵律：

### 絕對不要 Commit .env 到 Git

確認 `.gitignore` 裡有 `.env`。如果你不小心 commit 過，光是刪掉檔案不夠——歷史紀錄裡還在。你需要：

1. 立即更換所有外洩的密鑰（資料庫密碼、API Key 等）
2. 用 `git filter-branch` 或 BFG Repo-Cleaner 清除 Git 歷史

### 各部署方式的環境變數管理

**Forge：** 在伺服器的 Environment 頁面直接編輯。Forge 會把內容寫到伺服器上的 `.env` 檔案，並設定正確的檔案權限。

**Docker：** 兩種常見做法：

```yaml
# 方式一：env_file（適合開發和 CI）
services:
  app:
    env_file:
      - .env.production

# 方式二：Docker Secrets（適合正式環境）
services:
  app:
    secrets:
      - db_password
      - app_key

secrets:
  db_password:
    external: true
  app_key:
    external: true
```

**Cloud Run：** 使用 Google Cloud Secret Manager，在部署時透過 `--set-secrets` 把 secret 注入成環境變數：

```bash
# 建立 secret
echo -n &quot;your-database-password&quot; | gcloud secrets create db-password --data-file=-

# 部署時注入
gcloud run deploy jiuhaobuy \
  --set-secrets DB_PASSWORD=db-password:latest
```

### 金鑰輪替策略

好的安全實踐包括定期更換敏感的金鑰：

- **資料庫密碼**——每 90 天更換一次
- **第三方 API Key**——依照服務商建議的頻率
- **APP_KEY**——除非有外洩疑慮，否則不要換（會影響所有加密資料）
- **JWT Secret**——更換後所有舊的 token 會失效，要在低流量時段操作

每次更換後，更新部署平台的環境變數，然後重新部署或重啟應用。

## 實作：部署揪好買到正式環境

講了這麼多理論，現在來實際操作。我們提供兩條路線，挑一條適合你的跟著做。

### Option A：Forge 快速上線（5 步驟）

**步驟 1：** 到 [forge.laravel.com](https://forge.laravel.com) 註冊，連結你的 DigitalOcean 帳號。

**步驟 2：** 建立伺服器——選 DigitalOcean、新加坡區域（離台灣最近）、$6/月的 1GB Droplet。等待約 10 分鐘。

**步驟 3：** 新增 Site——填入 domain（例如 `jiuhaobuy.com`），連結 GitHub repo，選 `main` branch。

**步驟 4：** 設定環境變數——在 Forge 的 Environment 頁面，貼上你的 production `.env` 內容（記得改 `APP_ENV=production`、`APP_DEBUG=false`、設定正確的資料庫連線）。

**步驟 5：** 按下「Deploy Now」。Forge 會執行 deploy script：拉程式碼、裝依賴、跑 migration、清快取。部署完成後，到 SSL 區塊申請 Let&apos;s Encrypt 憑證。

完成。從註冊到上線大約 20-30 分鐘。

### Option B：Docker + Cloud Run（Dockerfile → Build → Deploy）

**步驟 1：** 確認專案根目錄有前面提到的 `Dockerfile`。

**步驟 2：** 在 Google Cloud 建立專案和 Artifact Registry：

```bash
# 建立 Artifact Registry
gcloud artifacts repositories create jiuhaobuy \
  --repository-format=docker \
  --location=asia-east1

# 設定 Secret Manager
echo -n &quot;base64:your-app-key&quot; | gcloud secrets create app-key --data-file=-
echo -n &quot;your-db-password&quot; | gcloud secrets create db-password --data-file=-
```

**步驟 3：** 建置並推送 image：

```bash
gcloud builds submit \
  --tag asia-east1-docker.pkg.dev/your-project/jiuhaobuy/app:v1.0.0
```

**步驟 4：** 部署到 Cloud Run：

```bash
gcloud run deploy jiuhaobuy \
  --image asia-east1-docker.pkg.dev/your-project/jiuhaobuy/app:v1.0.0 \
  --platform managed \
  --region asia-east1 \
  --allow-unauthenticated \
  --set-env-vars APP_ENV=production,APP_DEBUG=false,APP_URL=https://jiuhaobuy.com \
  --set-secrets APP_KEY=app-key:latest,DB_PASSWORD=db-password:latest \
  --min-instances 1 \
  --max-instances 10 \
  --memory 512Mi
```

**步驟 5：** 設定 custom domain 和執行 migration：

```bash
# 對應你的 domain
gcloud run domain-mappings create --service jiuhaobuy \
  --domain jiuhaobuy.com --region asia-east1

# 執行 migration（用 Cloud Run Jobs）
# 第一次要先建立 job——直接 execute 會得到 job not found
gcloud run jobs create jiuhaobuy-migrate \
  --image asia-east1-docker.pkg.dev/your-project/jiuhaobuy/app:latest \
  --region asia-east1 \
  --set-secrets APP_KEY=app-key:latest,DB_PASSWORD=db-password:latest \
  --command php \
  --args artisan,migrate,--force

# 建立後即可執行（之後每次部署重跑這行就好）
gcloud run jobs execute jiuhaobuy-migrate --region asia-east1
```

### 部署後 Checklist

不管你選哪條路，部署完成後走過這份 checklist：

```bash
# 1. 確認應用可以存取
curl -I https://jiuhaobuy.com          # 應該回 200

# 2. 確認 HTTPS 正常
curl -I http://jiuhaobuy.com           # 應該回 301 redirect 到 https

# 3. 確認 migration 已執行
php artisan migrate:status             # 所有 migration 應該都是 Ran

# 4. 確認 cache 已建立
php artisan optimize                   # 建立 config/route/view/event cache

# 5. 確認 Queue Worker 在運行
php artisan queue:monitor redis:default  # 檢查 queue 狀態

# 6. 確認 Scheduler 在運行
# 等一分鐘後檢查 logs，看 schedule:run 有沒有被觸發

# 7. 測試核心功能
# - 註冊/登入
# - 建立團購
# - 加入團購
# - 付款流程（用 Stripe test mode）
```

## 小結：選擇適合你的部署方式

部署不是一個技術問題，它是一個「我願意花多少時間在維運上」的決策。

**決策矩陣：**

- **一個人做 Side Project** → Forge。$18/月買回你的時間。
- **有 Docker 經驗、在意成本** → Docker + VPS。$6/月跑起來，但 server 你自己管。
- **已經在用 GCP/AWS** → Cloud Run / ECS。跟既有基礎設施整合最順。
- **想要極致效能** → 任何路線都可以加上 Octane。

這一章涵蓋了從上線前設定、效能快取、三種部署路線、SSL、零停機部署、到環境變數管理——這些是 Laravel 應用正式上線的完整知識。不管你選哪條路，核心步驟都一樣：設定環境變數、跑 migration、建立 cache、確保 queue worker 和 scheduler 在跑。

[下一章](/blog/laravel-guide-roadmap-next-steps/)是這本書的最後一章。我們會回顧整個「揪好買」的旅程、盤點你學到的所有技能、展望 Laravel 生態系裡還有哪些強大的工具等著你探索，然後幫你畫一張接下來的進階學習路線圖。你已經具備了從零到上線的完整能力——接下來的路，你可以自己走了。</content:encoded><media:content url="https://bobochen.dev/_astro/cover.B0YN06l1.webp" medium="image"/><category>PHP</category><category>Laravel</category><category>部署</category><category>Docker</category><category>Laravel Forge</category><category>Cloud Run</category><category>DevOps</category><enclosure url="https://bobochen.dev/_astro/cover.B0YN06l1.webp" length="0" type="image/png"/></item><item><title>測試不是選配：用 Pest 寫出有信心的 Laravel 程式</title><link>https://bobochen.dev/blog/laravel-guide-testing-pest-ci/</link><guid isPermaLink="true">https://bobochen.dev/blog/laravel-guide-testing-pest-ci/</guid><description>用 Pest 測試框架為 Laravel 12 應用寫單元測試與功能測試：actingAs 模擬登入、RefreshDatabase 隔離資料、Mail::fake() 與 Queue::fake() 攔截副作用，再搭配 GitHub Actions 建立 CI pipeline，讓每次 Push 都自動跑測試，從此敢重構、放心部署。</description><pubDate>Tue, 27 May 2025 00:00:00 GMT</pubDate><content:encoded>&gt; 本系列以 Laravel 12 為基準撰寫。Laravel 13 已於 2026 年 3 月 17 日正式發佈（最低需求 PHP 8.3），但本章的 Pest 測試內容在 Laravel 13 同樣適用，不需特別調整。

「在我電腦上可以跑啊。」——這大概是軟體開發史上最經典的一句話。你改了一個 Model 的欄位，結果另一個頁面的表單壞了；你重構了一段商業邏輯，結果付款流程默默失效了。沒有測試的程式碼，每一次修改都是一場賭博，而你遲早會輸。

PHP 社群過去對測試的態度確實比較隨意，但 Pest 測試框架的出現改變了這件事。Pest 是建立在 PHPUnit 之上的現代測試框架，語法簡潔到你會覺得寫測試跟寫文件一樣自然。搭配 Laravel 內建的測試工具，寫測試的門檻已經低到沒有藉口不寫了：

- **HTTP 測試**：模擬使用者操作，不需要真的開瀏覽器
- **RefreshDatabase**：讓每次測試都從乾淨的資料庫開始
- **Mail::fake()**：攔截寄信動作，不會真的發出 email
- **Queue::fake()**：攔截佇列工作，不需要跑 worker

這一章我們要為揪好買的核心流程寫完整測試：開團建立、跟團加入、成團確認、付款流程。然後把這些測試串進 GitHub Actions CI pipeline，讓每一次 Push 都自動跑測試。從此以後，你可以安心重構、放心部署，晚上也能睡好覺。

## 為什麼 Laravel 專案一定要寫測試：不是為了考試，是為了睡好覺

先講兩個真實場景。

**場景一：** 某電商平台的工程師收到需求——「折扣碼不能跟團購優惠同時使用」。他改了結帳 Controller 裡的幾行邏輯，本機測了一下沒問題就部署了。隔天早上，客服收到 50 通投訴：「我加購商品怎麼價格變成零？」。原來改動影響了加購邏輯的金額計算，而沒有人發現——因為沒有測試。

**場景二：** 另一個團隊要把 Laravel 從 10 升級到 11。他們有 400 多個測試案例，跑一次 3 分鐘。升級完跑測試，紅了 12 個。每一個紅掉的測試都精確地告訴他們哪裡壞了、預期行為是什麼。他們花了一個下午修完，信心滿滿地部署上線。

測試的價值不在於「證明程式碼正確」——你永遠無法證明複雜系統沒有 bug。測試的價值在三件事：

**1. 安全網：讓你敢重構。** 沒有測試的程式碼，大家只敢「加程式碼」，不敢「改程式碼」。久了之後，程式碼就變成一堆層層疊加的 if-else 怪物。有測試保護，你可以放心把醜陋的程式碼重構成漂亮的架構，因為跑一次測試就知道有沒有搞壞東西。

**2. 活文件：測試本身就是規格書。** 你看一個 Controller 的程式碼，可能要花 10 分鐘才搞懂它的行為。但你看對應的測試——「未登入的使用者嘗試開團時應該被導向登入頁」「開團時名稱是必填欄位」「團購人數已滿時不能再加入」——30 秒就知道這個功能在幹嘛。而且這份文件是「可以執行的」，它不會跟程式碼脫節。

**3. 設計回饋：難測的程式碼通常設計不好。** 如果你發現一個 class 很難寫測試——需要 mock 一堆東西、需要準備很多前置狀態——那通常代表這個 class 做了太多事情。測試會倒逼你寫出鬆耦合、職責分明的程式碼。

### 跨框架對照

| 概念       | Laravel (Pest)        | Jest (Node.js)      | pytest (Python) |
| ---------- | --------------------- | ------------------- | --------------- |
| 測試框架   | Pest / PHPUnit        | Jest / Vitest       | pytest          |
| 斷言語法   | `expect($x)-&gt;toBe(1)` | `expect(x).toBe(1)` | `assert x == 1` |
| HTTP 測試  | `$this-&gt;get(&apos;/api&apos;)`  | supertest           | TestClient      |
| 測試資料庫 | RefreshDatabase       | 自己管              | fixtures        |
| Mock       | `Mail::fake()`        | `jest.mock()`       | `unittest.mock` |
| 執行指令   | `php artisan test`    | `npm test`          | `pytest`        |

如果你來自 JavaScript 世界，Pest 的語法會讓你非常有親切感——它就是 PHP 版的 Jest。

## Pest 測試框架：比 PHPUnit 更好寫

Laravel 12 的 `laravel new` 安裝精靈會詢問你選擇 **Pest** 還是 **PHPUnit**；選了 Pest，它就會自動裝好、開箱即用。如果你用的是舊版 Laravel 或想手動安裝：

```bash
composer require pestphp/pest pestphp/pest-plugin-laravel --dev --with-all-dependencies
php artisan pest:install
```

`pest-plugin-laravel` 提供 `actingAs()`、Laravel 專屬斷言等整合功能；初始化建議用 `php artisan pest:install`（舊式 `./vendor/bin/pest --init` 仍可用，但新版以 `pest:install` 為主）。

以 Laravel 12 來說，只要在 `laravel new` 時選 Pest，就能直接使用，不需要額外執行上面的指令。

### Pest 語法 vs PHPUnit 語法

先來看同一個測試用兩種方式怎麼寫：

**PHPUnit（傳統方式）：**

```php
&lt;?php

namespace Tests\Feature;

use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;

class GroupBuyTest extends TestCase
{
    use RefreshDatabase;

    public function test_homepage_returns_successful_response(): void
    {
        $response = $this-&gt;get(&apos;/&apos;);

        $response-&gt;assertStatus(200);
    }

    public function test_guest_cannot_create_group_buy(): void
    {
        $response = $this-&gt;post(&apos;/group-buys&apos;, [
            &apos;title&apos; =&gt; &apos;大湖草莓團購&apos;,
        ]);

        $response-&gt;assertRedirect(&apos;/login&apos;);
    }
}
```

**Pest（現代方式）：**

```php
&lt;?php

use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

it(&apos;returns successful response for homepage&apos;, function () {
    $this-&gt;get(&apos;/&apos;)
        -&gt;assertStatus(200);
});

it(&apos;prevents guest from creating group buy&apos;, function () {
    $this-&gt;post(&apos;/group-buys&apos;, [
        &apos;title&apos; =&gt; &apos;大湖草莓團購&apos;,
    ])-&gt;assertRedirect(&apos;/login&apos;);
});
```

差異一目了然：

- **不需要 class** —— 每個測試檔案就是一堆函數，不用寫 `extends TestCase`
- **不需要 method 命名規範** —— 用 `it()` 或 `test()` 描述行為，讀起來像英文句子
- **不需要 use trait** —— `uses(RefreshDatabase::class)` 一行搞定
- **鏈式斷言** —— 所有東西串在一起，更流暢

### `it()` 和 `test()` 的差別

```php
// 這兩個完全等價：
it(&apos;creates a group buy successfully&apos;, function () { /* ... */ });
test(&apos;creates a group buy successfully&apos;, function () { /* ... */ });

// it() 在終端顯示為 &quot;it creates a group buy successfully&quot;
// test() 在終端顯示為 &quot;creates a group buy successfully&quot;
```

慣例上，`it()` 用來描述「這個東西應該做什麼」，`test()` 用來描述「做這件事的結果」。選一種風格並保持一致就好。本章統一用 `it()`。

### `expect()` API——流暢的斷言

Pest 的 `expect()` API 借鏡了 Jest，讀起來非常自然：

```php
// 基本型別
expect($user-&gt;name)-&gt;toBe(&apos;Bobo&apos;);
expect($user-&gt;age)-&gt;toBeGreaterThan(18);
expect($user-&gt;email)-&gt;toContain(&apos;@&apos;);
expect($user-&gt;bio)-&gt;toBeNull();
expect($user-&gt;is_active)-&gt;toBeTrue();

// 陣列
expect($tags)-&gt;toHaveCount(3);
expect($response-&gt;json())-&gt;toHaveKey(&apos;data&apos;);
expect($ids)-&gt;toContain(1, 2, 3);

// 例外
expect(fn () =&gt; $service-&gt;join($closedGroupBuy))
    -&gt;toThrow(GroupBuyClosedException::class);

// 鏈式
expect($user)
    -&gt;name-&gt;toBe(&apos;Bobo&apos;)
    -&gt;email-&gt;toEndWith(&apos;@example.com&apos;)
    -&gt;role-&gt;toBe(UserRole::Organizer);
```

### 跑測試

```bash
# 用 Artisan（推薦，有漂亮的輸出）
php artisan test

# 直接跑 Pest
./vendor/bin/pest

# 跑特定檔案
php artisan test --filter=GroupBuyTest

# 跑特定測試
php artisan test --filter=&quot;creates a group buy&quot;

# 平行執行（更快）
php artisan test --parallel

# 只跑上次失敗的
php artisan test --retry
```

`php artisan test` 的輸出非常漂亮——綠色的勾勾代表通過，紅色的叉叉代表失敗，還會告訴你每個測試花了多少毫秒。

## Feature Test vs Unit Test：差在哪裡

Laravel 的測試資料夾結構很清楚：

```
tests/
├── Feature/          # 功能測試（模擬完整 HTTP 請求）
│   ├── GroupBuyTest.php
│   └── Auth/
│       └── LoginTest.php
├── Unit/             # 單元測試（測試單一 class/method）
│   └── Models/
│       └── GroupBuyTest.php
├── Pest.php          # Pest 全域設定
└── TestCase.php      # 基底 TestCase
```

### Feature Test（功能測試）

功能測試模擬完整的 HTTP 請求生命週期——從路由解析、middleware 檢查、Controller 執行、到 Response 返回。它測試的是「使用者做了某個操作，系統會有什麼反應」：

```php
// tests/Feature/GroupBuyTest.php
it(&apos;allows organizer to create a group buy&apos;, function () {
    $user = User::factory()-&gt;create([&apos;role&apos; =&gt; &apos;organizer&apos;]);

    $this-&gt;actingAs($user)
        -&gt;post(&apos;/group-buys&apos;, [
            &apos;title&apos; =&gt; &apos;大湖草莓團購&apos;,
            &apos;description&apos; =&gt; &apos;又到了草莓季&apos;,
            &apos;min_participants&apos; =&gt; 10,
            &apos;max_participants&apos; =&gt; 50,
            &apos;deadline&apos; =&gt; now()-&gt;addWeek(),
        ])
        -&gt;assertRedirect(&apos;/group-buys&apos;);

    $this-&gt;assertDatabaseHas(&apos;group_buys&apos;, [
        &apos;title&apos; =&gt; &apos;大湖草莓團購&apos;,
        &apos;user_id&apos; =&gt; $user-&gt;id,
    ]);
});
```

這一個測試涵蓋了：路由是否正確、`auth` middleware 是否通過、Controller 是否正確處理資料、資料是否寫入資料庫、回應是否重導到正確頁面。

### Unit Test（單元測試）

單元測試只測試一個 class 或 method 的行為，不經過 HTTP 層，不碰資料庫（除非必要）：

```php
// tests/Unit/Models/GroupBuyTest.php
it(&apos;calculates if group buy has reached minimum participants&apos;, function () {
    $groupBuy = new GroupBuy([
        &apos;min_participants&apos; =&gt; 10,
    ]);

    // 用 mock 或直接設定 relationship count
    expect($groupBuy-&gt;hasReachedMinimum(8))-&gt;toBeFalse();
    expect($groupBuy-&gt;hasReachedMinimum(10))-&gt;toBeTrue();
    expect($groupBuy-&gt;hasReachedMinimum(15))-&gt;toBeTrue();
});

it(&apos;determines if group buy is still open&apos;, function () {
    $open = new GroupBuy([&apos;deadline&apos; =&gt; now()-&gt;addDay()]);
    $closed = new GroupBuy([&apos;deadline&apos; =&gt; now()-&gt;subDay()]);

    expect($open-&gt;isOpen())-&gt;toBeTrue();
    expect($closed-&gt;isOpen())-&gt;toBeFalse();
});
```

### 什麼時候用哪個？

| 情境                  | 選擇    | 原因                   |
| --------------------- | ------- | ---------------------- |
| 測試 API endpoint     | Feature | 需要完整 HTTP 生命週期 |
| 測試使用者流程        | Feature | 涉及多個元件協作       |
| 測試 Model 的計算邏輯 | Unit    | 純函數，不需要 HTTP    |
| 測試 Service class    | Unit    | 單一職責，注入依賴     |
| 測試授權 Policy       | Feature | 需要 auth 上下文       |
| 測試 Validation 規則  | Feature | 需要 Request 處理      |

&gt; **經驗法則：** 對於 Web 應用程式，**Feature Test 的投資報酬率最高**。一個 Feature Test 就能涵蓋路由、middleware、Controller、Model、View 的整合。先把核心流程的 Feature Test 寫好，再慢慢補 Unit Test 給複雜的商業邏輯。

### Pest.php：全域設定

`tests/Pest.php` 是 Pest 的全域設定檔，你可以在這裡為不同資料夾的測試統一套用 trait：

```php
// tests/Pest.php

uses(Tests\TestCase::class, Illuminate\Foundation\Testing\RefreshDatabase::class)
    -&gt;in(&apos;Feature&apos;);

uses(Tests\TestCase::class)
    -&gt;in(&apos;Unit&apos;);
```

這樣你就不需要在每個 Feature 測試檔案裡都寫 `uses(RefreshDatabase::class)` 了——全域一次搞定。

## HTTP Tests：模擬使用者操作

Laravel 的 HTTP 測試是你最常用的武器。它讓你模擬瀏覽器的行為——送出 GET、POST、PUT、DELETE 請求，然後檢查回應。

### 基本 HTTP 方法

```php
// GET 請求——瀏覽頁面
$this-&gt;get(&apos;/group-buys&apos;)
    -&gt;assertStatus(200)
    -&gt;assertSee(&apos;大湖草莓團購&apos;);

// POST 請求——建立資源
$this-&gt;post(&apos;/group-buys&apos;, [
    &apos;title&apos; =&gt; &apos;大湖草莓團購&apos;,
    &apos;min_participants&apos; =&gt; 10,
])-&gt;assertRedirect(&apos;/group-buys&apos;);

// PUT 請求——更新資源
$this-&gt;put(&quot;/group-buys/{$groupBuy-&gt;id}&quot;, [
    &apos;title&apos; =&gt; &apos;大湖有機草莓團購&apos;,
])-&gt;assertRedirect(&quot;/group-buys/{$groupBuy-&gt;id}&quot;);

// DELETE 請求——刪除資源
$this-&gt;delete(&quot;/group-buys/{$groupBuy-&gt;id}&quot;)
    -&gt;assertRedirect(&apos;/group-buys&apos;);
```

### 常用斷言方法

```php
// 狀態碼
-&gt;assertStatus(200)
-&gt;assertOk()               // 等同 assertStatus(200)
-&gt;assertNotFound()          // 等同 assertStatus(404)
-&gt;assertForbidden()         // 等同 assertStatus(403)
-&gt;assertUnauthorized()      // 等同 assertStatus(401)

// 重導向
-&gt;assertRedirect(&apos;/login&apos;)
-&gt;assertRedirectToRoute(&apos;group-buys.index&apos;)

// 頁面內容
-&gt;assertSee(&apos;大湖草莓團購&apos;)           // 頁面包含這段文字
-&gt;assertDontSee(&apos;已截止&apos;)             // 頁面不包含這段文字
-&gt;assertSeeText(&apos;10 人成團&apos;)          // 只看純文字（忽略 HTML）

// Session
-&gt;assertSessionHas(&apos;success&apos;, &apos;開團成功！&apos;)
-&gt;assertSessionHasErrors([&apos;title&apos;])   // 驗證失敗時的錯誤欄位
-&gt;assertSessionHasNoErrors()

// View
-&gt;assertViewIs(&apos;group-buys.show&apos;)
-&gt;assertViewHas(&apos;groupBuy&apos;)
```

### 模擬登入使用者

```php
use App\Models\User;

it(&apos;shows create form to organizers&apos;, function () {
    $organizer = User::factory()-&gt;create([&apos;role&apos; =&gt; &apos;organizer&apos;]);

    $this-&gt;actingAs($organizer)
        -&gt;get(&apos;/group-buys/create&apos;)
        -&gt;assertOk();
});

it(&apos;denies create form to regular members&apos;, function () {
    $member = User::factory()-&gt;create([&apos;role&apos; =&gt; &apos;member&apos;]);

    $this-&gt;actingAs($member)
        -&gt;get(&apos;/group-buys/create&apos;)
        -&gt;assertForbidden();
});

it(&apos;redirects guest to login&apos;, function () {
    $this-&gt;get(&apos;/group-buys/create&apos;)
        -&gt;assertRedirect(&apos;/login&apos;);
});
```

`actingAs()` 幫你模擬「以某個使用者身份登入」，不需要真的跑登入流程。

### 測試 JSON API 回應

```php
it(&apos;returns group buys as JSON&apos;, function () {
    GroupBuy::factory()-&gt;count(3)-&gt;create();

    $this-&gt;getJson(&apos;/api/group-buys&apos;)
        -&gt;assertOk()
        -&gt;assertJsonCount(3, &apos;data&apos;)
        -&gt;assertJsonStructure([
            &apos;data&apos; =&gt; [
                &apos;*&apos; =&gt; [&apos;id&apos;, &apos;title&apos;, &apos;description&apos;, &apos;min_participants&apos;, &apos;deadline&apos;],
            ],
        ]);
});

it(&apos;returns specific group buy details&apos;, function () {
    $groupBuy = GroupBuy::factory()-&gt;create([
        &apos;title&apos; =&gt; &apos;大湖草莓團購&apos;,
    ]);

    $this-&gt;getJson(&quot;/api/group-buys/{$groupBuy-&gt;id}&quot;)
        -&gt;assertOk()
        -&gt;assertJson([
            &apos;data&apos; =&gt; [
                &apos;title&apos; =&gt; &apos;大湖草莓團購&apos;,
            ],
        ]);
});
```

注意這裡用的是 `getJson()` 而不是 `get()`——它會自動帶上 `Accept: application/json` header，讓 Laravel 回傳 JSON 而不是 HTML。對應的還有 `postJson()`、`putJson()`、`deleteJson()`。

## Database Testing：RefreshDatabase 的魔力

測試最怕的是「測試之間互相影響」。你在測試 A 建了一個使用者，結果測試 B 因為資料庫裡多了這筆資料而失敗——這種問題 debug 起來讓人抓狂。

### RefreshDatabase trait

`RefreshDatabase` 解決這個問題的方式很聰明：它在每個測試之前用資料庫 transaction 包起來，測試結束後 rollback。效果等同於每個測試都從空白資料庫開始，但速度比真的 `migrate:fresh` 快得多。

如果你按前面建議設定了 `tests/Pest.php`，所有 Feature 測試都自動有這個行為：

```php
// tests/Pest.php
uses(Tests\TestCase::class, RefreshDatabase::class)-&gt;in(&apos;Feature&apos;);
```

### 資料庫斷言

```php
use App\Models\GroupBuy;
use App\Models\User;

it(&apos;stores group buy in database&apos;, function () {
    $user = User::factory()-&gt;create([&apos;role&apos; =&gt; &apos;organizer&apos;]);

    $this-&gt;actingAs($user)-&gt;post(&apos;/group-buys&apos;, [
        &apos;title&apos; =&gt; &apos;大湖草莓團購&apos;,
        &apos;description&apos; =&gt; &apos;又到了草莓季&apos;,
        &apos;min_participants&apos; =&gt; 10,
        &apos;max_participants&apos; =&gt; 50,
        &apos;deadline&apos; =&gt; &apos;2026-12-31&apos;,
    ]);

    // 確認資料庫裡有這筆資料
    $this-&gt;assertDatabaseHas(&apos;group_buys&apos;, [
        &apos;title&apos; =&gt; &apos;大湖草莓團購&apos;,
        &apos;user_id&apos; =&gt; $user-&gt;id,
    ]);

    // 確認資料庫裡有正確的數量
    $this-&gt;assertDatabaseCount(&apos;group_buys&apos;, 1);
});

it(&apos;removes group buy from database on delete&apos;, function () {
    $user = User::factory()-&gt;create([&apos;role&apos; =&gt; &apos;organizer&apos;]);
    $groupBuy = GroupBuy::factory()-&gt;for($user)-&gt;create();

    $this-&gt;actingAs($user)-&gt;delete(&quot;/group-buys/{$groupBuy-&gt;id}&quot;);

    // 確認資料庫裡沒有這筆資料了
    $this-&gt;assertDatabaseMissing(&apos;group_buys&apos;, [
        &apos;id&apos; =&gt; $groupBuy-&gt;id,
    ]);
});
```

### 使用 SQLite in-memory 加速測試

在 `phpunit.xml` 裡（Laravel 12 預設已經設好了），測試環境使用 SQLite in-memory 資料庫，跑起來飛快：

```xml
&lt;php&gt;
    &lt;env name=&quot;APP_ENV&quot; value=&quot;testing&quot;/&gt;
    &lt;env name=&quot;DB_CONNECTION&quot; value=&quot;sqlite&quot;/&gt;
    &lt;env name=&quot;DB_DATABASE&quot; value=&quot;:memory:&quot;/&gt;
&lt;/php&gt;
```

這代表測試不會碰到你的開發資料庫——完全隔離。

## Mock 與 Fake：Mail::fake()、Queue::fake()

測試不應該真的寄信、真的打第三方 API、真的觸發排程任務。Laravel 的 Fake 機制讓你攔截這些副作用，只檢查「系統是否正確地觸發了這些操作」。

### Mail::fake()

```php
use Illuminate\Support\Facades\Mail;
use App\Mail\GroupBuyConfirmed;

it(&apos;sends confirmation email when group buy reaches minimum&apos;, function () {
    Mail::fake();

    $groupBuy = GroupBuy::factory()-&gt;create([&apos;min_participants&apos; =&gt; 2]);
    $participants = User::factory()-&gt;count(2)-&gt;create();

    // 模擬兩個人加入，觸發成團
    foreach ($participants as $participant) {
        $groupBuy-&gt;participants()-&gt;attach($participant);
    }

    $groupBuy-&gt;checkAndConfirm();

    // 斷言：確認信被寄出了
    Mail::assertSent(GroupBuyConfirmed::class, function ($mail) use ($groupBuy) {
        return $mail-&gt;groupBuy-&gt;id === $groupBuy-&gt;id;
    });

    // 斷言：寄了正確的數量
    Mail::assertSent(GroupBuyConfirmed::class, 2);
});

it(&apos;does not send email when minimum not reached&apos;, function () {
    Mail::fake();

    $groupBuy = GroupBuy::factory()-&gt;create([&apos;min_participants&apos; =&gt; 10]);
    $groupBuy-&gt;participants()-&gt;attach(User::factory()-&gt;create());

    $groupBuy-&gt;checkAndConfirm();

    Mail::assertNotSent(GroupBuyConfirmed::class);
});
```

`Mail::fake()` 攔截所有郵件，不會真的送出。你只需要驗證「正確的 Mailable 是否被送出、送給了誰」。

### Queue::fake()

```php
use Illuminate\Support\Facades\Queue;
use App\Jobs\ProcessGroupBuyPayment;

it(&apos;dispatches payment job when group buy is confirmed&apos;, function () {
    Queue::fake();

    $groupBuy = GroupBuy::factory()-&gt;confirmed()-&gt;create();

    $groupBuy-&gt;processPayments();

    Queue::assertPushed(ProcessGroupBuyPayment::class, function ($job) use ($groupBuy) {
        return $job-&gt;groupBuyId === $groupBuy-&gt;id;
    });
});
```

### Notification::fake()

```php
use Illuminate\Support\Facades\Notification;
use App\Notifications\GroupBuyDeadlineReminder;

it(&apos;sends deadline reminder to all participants&apos;, function () {
    Notification::fake();

    $groupBuy = GroupBuy::factory()-&gt;create([
        &apos;deadline&apos; =&gt; now()-&gt;addDay(),
    ]);
    $participants = User::factory()-&gt;count(5)-&gt;create();
    $groupBuy-&gt;participants()-&gt;attach($participants);

    $groupBuy-&gt;sendDeadlineReminders();

    Notification::assertSentTo($participants, GroupBuyDeadlineReminder::class);
});
```

### Event::fake()

```php
use Illuminate\Support\Facades\Event;
use App\Events\GroupBuyCreated;

it(&apos;fires event when group buy is created&apos;, function () {
    Event::fake([GroupBuyCreated::class]);

    $user = User::factory()-&gt;create([&apos;role&apos; =&gt; &apos;organizer&apos;]);

    $this-&gt;actingAs($user)-&gt;post(&apos;/group-buys&apos;, [
        &apos;title&apos; =&gt; &apos;大湖草莓團購&apos;,
        &apos;min_participants&apos; =&gt; 10,
        &apos;max_participants&apos; =&gt; 50,
        &apos;deadline&apos; =&gt; now()-&gt;addWeek(),
    ]);

    Event::assertDispatched(GroupBuyCreated::class);
});
```

### Storage::fake()

```php
use Illuminate\Support\Facades\Storage;
use Illuminate\Http\UploadedFile;

it(&apos;uploads product image when creating group buy&apos;, function () {
    Storage::fake(&apos;public&apos;);

    $user = User::factory()-&gt;create([&apos;role&apos; =&gt; &apos;organizer&apos;]);
    $image = UploadedFile::fake()-&gt;image(&apos;strawberry.jpg&apos;, 800, 600);

    $this-&gt;actingAs($user)-&gt;post(&apos;/group-buys&apos;, [
        &apos;title&apos; =&gt; &apos;大湖草莓團購&apos;,
        &apos;image&apos; =&gt; $image,
        &apos;min_participants&apos; =&gt; 10,
        &apos;max_participants&apos; =&gt; 50,
        &apos;deadline&apos; =&gt; now()-&gt;addWeek(),
    ]);

    // 斷言：檔案確實被存到 public disk
    Storage::disk(&apos;public&apos;)-&gt;assertExists(&apos;group-buys/&apos; . $image-&gt;hashName());
});
```

`Storage::fake(&apos;public&apos;)` 建立一個 in-memory 檔案系統，不會真的寫檔案到磁碟。測試結束後自動清空。

## 測試資料準備：Factory 的進階用法

[第五章](/blog/laravel-guide-eloquent-orm-models/)已經介紹過 Factory 的基本用法。在測試情境裡，Factory 的進階功能會讓你的測試更簡潔、更有表達力。

### Factory States：用名稱描述狀態

```php
// database/factories/GroupBuyFactory.php

class GroupBuyFactory extends Factory
{
    public function definition(): array
    {
        return [
            &apos;user_id&apos; =&gt; User::factory(),
            &apos;title&apos; =&gt; fake()-&gt;sentence(3),
            &apos;description&apos; =&gt; fake()-&gt;paragraph(),
            &apos;min_participants&apos; =&gt; fake()-&gt;numberBetween(5, 20),
            &apos;max_participants&apos; =&gt; fake()-&gt;numberBetween(20, 100),
            &apos;deadline&apos; =&gt; fake()-&gt;dateTimeBetween(&apos;+1 week&apos;, &apos;+1 month&apos;),
            &apos;status&apos; =&gt; &apos;open&apos;,
        ];
    }

    // State：已確認成團
    public function confirmed(): static
    {
        return $this-&gt;state(fn (array $attributes) =&gt; [
            &apos;status&apos; =&gt; &apos;confirmed&apos;,
            &apos;confirmed_at&apos; =&gt; now(),
        ]);
    }

    // State：已截止
    public function expired(): static
    {
        return $this-&gt;state(fn (array $attributes) =&gt; [
            &apos;deadline&apos; =&gt; now()-&gt;subDay(),
            &apos;status&apos; =&gt; &apos;expired&apos;,
        ]);
    }

    // State：已取消
    public function cancelled(): static
    {
        return $this-&gt;state(fn (array $attributes) =&gt; [
            &apos;status&apos; =&gt; &apos;cancelled&apos;,
            &apos;cancelled_at&apos; =&gt; now(),
        ]);
    }

    // State：已滿團
    public function full(): static
    {
        return $this-&gt;state(fn (array $attributes) =&gt; [
            &apos;max_participants&apos; =&gt; 2,
        ])-&gt;afterCreating(function (GroupBuy $groupBuy) {
            $groupBuy-&gt;participants()-&gt;attach(
                User::factory()-&gt;count(2)-&gt;create()
            );
        });
    }
}
```

使用起來非常直覺：

```php
$openGroupBuy = GroupBuy::factory()-&gt;create();
$confirmedGroupBuy = GroupBuy::factory()-&gt;confirmed()-&gt;create();
$expiredGroupBuy = GroupBuy::factory()-&gt;expired()-&gt;create();
$fullGroupBuy = GroupBuy::factory()-&gt;full()-&gt;create();
```

### Factory Relationships：`has()` 和 `for()`

```php
// 建立一個開團主，有 3 個團購
$organizer = User::factory()
    -&gt;has(GroupBuy::factory()-&gt;count(3))
    -&gt;create([&apos;role&apos; =&gt; &apos;organizer&apos;]);

// 建立一個團購，屬於特定使用者
$groupBuy = GroupBuy::factory()
    -&gt;for($organizer)
    -&gt;create();

// 建立一個團購，帶有 5 個參與者
$groupBuy = GroupBuy::factory()
    -&gt;hasParticipants(5)     // 等同 has(User::factory()-&gt;count(5), &apos;participants&apos;)
    -&gt;create();
```

### Factory Sequences：輪替值

```php
// 交替建立不同狀態的團購
$groupBuys = GroupBuy::factory()
    -&gt;count(6)
    -&gt;sequence(
        [&apos;status&apos; =&gt; &apos;open&apos;],
        [&apos;status&apos; =&gt; &apos;confirmed&apos;],
        [&apos;status&apos; =&gt; &apos;expired&apos;],
    )
    -&gt;create();
// 結果：open, confirmed, expired, open, confirmed, expired
```

### `recycle()`：在多個 Factory 之間共用 Model

```php
// 同一個使用者同時是開團主和其他團的參與者
$user = User::factory()-&gt;create();

$groupBuys = GroupBuy::factory()
    -&gt;count(3)
    -&gt;recycle($user)   // 所有 user_id 都用這個使用者
    -&gt;create();
```

`recycle()` 避免了 Factory 每次都建一個新的關聯 Model——當你需要多個 Factory 共用同一筆資料時特別有用。

## GitHub Actions CI：每次 Push 自動跑測試

測試寫好了，但如果要「記得手動跑」才有用，那遲早會有人忘記。CI（Continuous Integration）的目的就是：每次有人 push 程式碼，自動跑測試。測試過了才能合併。

### 完整的 GitHub Actions 設定

在專案根目錄建立 `.github/workflows/tests.yml`：

```yaml
name: Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  tests:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: &apos;8.4&apos;
          extensions: mbstring, xml, ctype, json, bcmath, sqlite3
          coverage: none

      - name: Cache Composer dependencies
        uses: actions/cache@v4
        with:
          path: vendor
          key: composer-${{ hashFiles(&apos;composer.lock&apos;) }}
          restore-keys: composer-

      - name: Install dependencies
        run: composer install --no-interaction --prefer-dist --optimize-autoloader

      - name: Copy environment file
        run: cp .env.example .env

      - name: Generate application key
        run: php artisan key:generate

      - name: Run tests
        env:
          DB_CONNECTION: sqlite
          DB_DATABASE: &apos;:memory:&apos;
        run: php artisan test --parallel
```

逐步拆解：

1. **觸發條件** —— push 到 `main` 或開 PR 到 `main` 時觸發
2. **PHP 設定** —— 安裝 PHP 8.4 和必要的擴充套件
3. **Composer Cache** —— 快取 `vendor/` 目錄，避免每次都重裝套件（省 30-60 秒）
4. **SQLite in-memory** —— 不需要真的 MySQL server，用 SQLite 跑測試更快
5. **平行執行** —— `--parallel` 讓測試跑更快

### 把 Badge 加到 README

```markdown
![Tests](https://github.com/your-username/jiu-hao-mai/actions/workflows/tests.yml/badge.svg)
```

這個 badge 會顯示在 README 最上方，讓所有人一眼就知道測試有沒有通過。綠色的 &quot;passing&quot; 就是信任的象徵。

### 當 CI 失敗了

CI 紅了不可怕，可怕的是忽視它。當 CI 失敗時：

1. **點進 GitHub Actions 看 log** —— 找到紅色的步驟，看錯誤訊息
2. **在本機重現** —— `php artisan test --filter=&quot;失敗的測試名稱&quot;`
3. **修好它** —— 不要跳過失敗的測試（`-&gt;skip()`），除非有非常正當的理由
4. **Push 修正** —— CI 會自動重新跑

&gt; **絕對不要做的事：** 刪掉失敗的測試、在 CI 裡加 `continue-on-error: true`、或用 `@skip` 跳過。這些做法只是在掩耳盜鈴。

## 實作：為揪好買核心流程寫測試

理論講完了，現在動手。我們要為揪好買寫一組完整的 Feature Test，覆蓋最重要的使用者流程。

建立測試檔案：

```bash
php artisan make:test GroupBuyTest
# 生成 tests/Feature/GroupBuyTest.php
```

```php
&lt;?php

// tests/Feature/GroupBuyTest.php

use App\Models\User;
use App\Models\GroupBuy;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Notification;
use App\Mail\GroupBuyConfirmed;
use App\Notifications\GroupBuyJoined;

// ── 開團建立 ──────────────────────────────────────

it(&apos;allows authenticated organizer to create a group buy&apos;, function () {
    $organizer = User::factory()-&gt;create([&apos;role&apos; =&gt; &apos;organizer&apos;]);

    $this-&gt;actingAs($organizer)
        -&gt;post(&apos;/group-buys&apos;, [
            &apos;title&apos; =&gt; &apos;大湖草莓團購&apos;,
            &apos;description&apos; =&gt; &apos;苗栗大湖有機草莓，產地直送&apos;,
            &apos;min_participants&apos; =&gt; 10,
            &apos;max_participants&apos; =&gt; 50,
            &apos;deadline&apos; =&gt; now()-&gt;addWeek()-&gt;toDateString(),
        ])
        -&gt;assertRedirect(&apos;/group-buys&apos;);

    $this-&gt;assertDatabaseHas(&apos;group_buys&apos;, [
        &apos;title&apos; =&gt; &apos;大湖草莓團購&apos;,
        &apos;user_id&apos; =&gt; $organizer-&gt;id,
        &apos;status&apos; =&gt; &apos;open&apos;,
    ]);
});

it(&apos;rejects group buy creation with missing required fields&apos;, function () {
    $organizer = User::factory()-&gt;create([&apos;role&apos; =&gt; &apos;organizer&apos;]);

    $this-&gt;actingAs($organizer)
        -&gt;post(&apos;/group-buys&apos;, [
            // title 沒填
            &apos;min_participants&apos; =&gt; 10,
        ])
        -&gt;assertSessionHasErrors([&apos;title&apos;, &apos;max_participants&apos;, &apos;deadline&apos;]);
});

it(&apos;prevents guest from creating a group buy&apos;, function () {
    $this-&gt;post(&apos;/group-buys&apos;, [
        &apos;title&apos; =&gt; &apos;大湖草莓團購&apos;,
        &apos;min_participants&apos; =&gt; 10,
        &apos;max_participants&apos; =&gt; 50,
        &apos;deadline&apos; =&gt; now()-&gt;addWeek()-&gt;toDateString(),
    ])-&gt;assertRedirect(&apos;/login&apos;);
});

it(&apos;prevents regular member from creating a group buy&apos;, function () {
    $member = User::factory()-&gt;create([&apos;role&apos; =&gt; &apos;member&apos;]);

    $this-&gt;actingAs($member)
        -&gt;post(&apos;/group-buys&apos;, [
            &apos;title&apos; =&gt; &apos;大湖草莓團購&apos;,
            &apos;min_participants&apos; =&gt; 10,
            &apos;max_participants&apos; =&gt; 50,
            &apos;deadline&apos; =&gt; now()-&gt;addWeek()-&gt;toDateString(),
        ])
        -&gt;assertForbidden();
});

// ── 跟團加入 ──────────────────────────────────────

it(&apos;allows authenticated user to join an open group buy&apos;, function () {
    $user = User::factory()-&gt;create();
    $groupBuy = GroupBuy::factory()-&gt;create([&apos;status&apos; =&gt; &apos;open&apos;]);

    $this-&gt;actingAs($user)
        -&gt;post(&quot;/group-buys/{$groupBuy-&gt;id}/join&quot;)
        -&gt;assertRedirect(&quot;/group-buys/{$groupBuy-&gt;id}&quot;);

    $this-&gt;assertDatabaseHas(&apos;group_buy_user&apos;, [
        &apos;user_id&apos; =&gt; $user-&gt;id,
        &apos;group_buy_id&apos; =&gt; $groupBuy-&gt;id,
    ]);
});

it(&apos;prevents joining a group buy that is already full&apos;, function () {
    $user = User::factory()-&gt;create();
    $groupBuy = GroupBuy::factory()-&gt;full()-&gt;create();

    $this-&gt;actingAs($user)
        -&gt;post(&quot;/group-buys/{$groupBuy-&gt;id}/join&quot;)
        -&gt;assertStatus(422)
        -&gt;assertSessionHasErrors([&apos;capacity&apos;]);
});

it(&apos;prevents duplicate join to the same group buy&apos;, function () {
    $user = User::factory()-&gt;create();
    $groupBuy = GroupBuy::factory()-&gt;create([&apos;status&apos; =&gt; &apos;open&apos;]);
    $groupBuy-&gt;participants()-&gt;attach($user);

    $this-&gt;actingAs($user)
        -&gt;post(&quot;/group-buys/{$groupBuy-&gt;id}/join&quot;)
        -&gt;assertStatus(422)
        -&gt;assertSessionHasErrors([&apos;duplicate&apos;]);
});

// ── 成團確認 ──────────────────────────────────────

it(&apos;confirms group buy when minimum participants reached&apos;, function () {
    Mail::fake();

    $groupBuy = GroupBuy::factory()-&gt;create([
        &apos;min_participants&apos; =&gt; 3,
        &apos;status&apos; =&gt; &apos;open&apos;,
    ]);

    $participants = User::factory()-&gt;count(3)-&gt;create();
    foreach ($participants as $participant) {
        $groupBuy-&gt;participants()-&gt;attach($participant);
    }

    $groupBuy-&gt;checkAndConfirm();

    expect($groupBuy-&gt;fresh()-&gt;status)-&gt;toBe(&apos;confirmed&apos;);

    Mail::assertSent(GroupBuyConfirmed::class, 3);
});

it(&apos;does not confirm group buy below minimum participants&apos;, function () {
    $groupBuy = GroupBuy::factory()-&gt;create([
        &apos;min_participants&apos; =&gt; 10,
        &apos;status&apos; =&gt; &apos;open&apos;,
    ]);

    $groupBuy-&gt;participants()-&gt;attach(User::factory()-&gt;create());

    $groupBuy-&gt;checkAndConfirm();

    expect($groupBuy-&gt;fresh()-&gt;status)-&gt;toBe(&apos;open&apos;);
});

// ── API Endpoints ─────────────────────────────────

it(&apos;returns paginated group buys as JSON&apos;, function () {
    GroupBuy::factory()-&gt;count(15)-&gt;create([&apos;status&apos; =&gt; &apos;open&apos;]);

    $this-&gt;getJson(&apos;/api/group-buys&apos;)
        -&gt;assertOk()
        -&gt;assertJsonStructure([
            &apos;data&apos; =&gt; [
                &apos;*&apos; =&gt; [
                    &apos;id&apos;,
                    &apos;title&apos;,
                    &apos;description&apos;,
                    &apos;min_participants&apos;,
                    &apos;max_participants&apos;,
                    &apos;deadline&apos;,
                    &apos;status&apos;,
                    &apos;participants_count&apos;,
                ],
            ],
            &apos;meta&apos; =&gt; [&apos;current_page&apos;, &apos;last_page&apos;, &apos;total&apos;],
        ]);
});

it(&apos;requires authentication for API group buy creation&apos;, function () {
    $this-&gt;postJson(&apos;/api/group-buys&apos;, [
        &apos;title&apos; =&gt; &apos;大湖草莓團購&apos;,
    ])-&gt;assertUnauthorized();
});
```

讓我們回顧這 11 個測試案例涵蓋了什麼：

| #   | 測試案例           | 驗證重點               |
| --- | ------------------ | ---------------------- |
| 1   | 開團主成功開團     | 認證 + 授權 + 資料寫入 |
| 2   | 缺少必填欄位       | 表單驗證               |
| 3   | 未登入者嘗試開團   | 認證 middleware        |
| 4   | 一般會員嘗試開團   | 授權 Policy            |
| 5   | 加入開放中的團購   | 正常流程 + pivot table |
| 6   | 加入已滿的團購     | 容量限制               |
| 7   | 重複加入同一團購   | 業務規則               |
| 8   | 成團確認 + 寄信    | 商業邏輯 + Mail::fake  |
| 9   | 未達最低人數不成團 | 邊界條件               |
| 10  | API 回應結構       | JSON 格式 + 分頁       |
| 11  | API 認證           | Token 認證             |

跑測試：

```bash
php artisan test tests/Feature/GroupBuyTest.php

#  PASS  Tests\Feature\GroupBuyTest
#  ✓ it allows authenticated organizer to create a group buy      0.15s
#  ✓ it rejects group buy creation with missing required fields   0.08s
#  ✓ it prevents guest from creating a group buy                  0.05s
#  ✓ it prevents regular member from creating a group buy         0.06s
#  ✓ it allows authenticated user to join an open group buy       0.09s
#  ✓ it prevents joining a group buy that is already full         0.07s
#  ✓ it prevents duplicate join to the same group buy             0.06s
#  ✓ it confirms group buy when minimum participants reached      0.11s
#  ✓ it does not confirm group buy below minimum participants     0.07s
#  ✓ it returns paginated group buys as JSON                      0.12s
#  ✓ it requires authentication for API group buy creation        0.04s
#
#  Tests:    11 passed (23 assertions)
#  Duration: 0.90s
```

全綠。11 個測試、23 個斷言、不到 1 秒。這就是你的安全網。

## 小結：有測試的程式碼，才是專業的程式碼

這一章我們建立了完整的測試體系：

**測試框架：**

- Pest 是 Laravel 12 官方一級支援、可在建立專案時直接選用的框架——語法簡潔、讀起來像英文、`expect()` API 直覺好用
- `it()` 描述行為、`expect()` 驗證結果、`uses()` 套用 trait
- `php artisan test` 一行指令跑所有測試

**測試類型：**

- Feature Test 模擬完整 HTTP 請求，投資報酬率最高
- Unit Test 測試單一 class/method 的純邏輯
- 先寫 Feature Test 覆蓋核心流程，再補 Unit Test 給複雜邏輯

**測試工具：**

- `actingAs()` 模擬登入使用者
- `RefreshDatabase` 確保每個測試從乾淨資料庫開始
- `Mail::fake()`、`Queue::fake()`、`Notification::fake()` 攔截副作用
- `Storage::fake()` 建立 in-memory 檔案系統
- Factory States 讓測試資料準備更有表達力

**CI Pipeline：**

- GitHub Actions 讓每次 push 自動跑測試
- SQLite in-memory + Composer cache 讓 CI 跑得快
- 測試紅了就修，不要跳過、不要忽視

**揪好買進度：**

- ✅ 11 個 Feature Test 涵蓋開團、跟團、成團、API
- ✅ Factory States 定義 `confirmed()`、`expired()`、`full()` 等狀態
- ✅ GitHub Actions CI pipeline 設定完成
- ✅ 核心流程全部有測試保護

有了測試保護，你就可以放心做任何改動。[下一章](/blog/laravel-guide-deployment-forge-docker/)我們要把揪好買部署上線——從 Production 環境設定、Config Cache、到 Laravel Forge 一鍵部署和 Docker 容器化。從此以後，你的程式碼不只在本機能跑，在全世界都能跑。</content:encoded><media:content url="https://bobochen.dev/_astro/cover.0NHauOEU.webp" medium="image"/><category>PHP</category><category>Laravel</category><category>Pest</category><category>Testing</category><category>CI</category><category>GitHub Actions</category><enclosure url="https://bobochen.dev/_astro/cover.0NHauOEU.webp" length="0" type="image/png"/></item><item><title>後台管理與進階查詢：用 Filament 打造管理介面</title><link>https://bobochen.dev/blog/laravel-guide-admin-filament-advanced-queries/</link><guid isPermaLink="true">https://bobochen.dev/blog/laravel-guide-admin-filament-advanced-queries/</guid><description>用 Filament 5 快速建立後台管理系統，搭配進階 Eloquent 查詢技巧解決真實世界的效能問題。</description><pubDate>Tue, 20 May 2025 00:00:00 GMT</pubDate><content:encoded>每一個有使用者的平台，遲早都需要一個管理後台。你可以從零開始刻——自己寫列表頁、編輯頁、篩選器、圖表——但說實話，那些重複的 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（年營收 &lt; $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

```bash
composer require filament/filament
```

安裝 Panel Builder 並設定管理面板：

```bash
php artisan filament:install --panels
```

這條指令會做幾件事：

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

### 建立管理員帳號

```bash
php artisan make:filament-user
```

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

&gt; **注意：** Filament 預設使用 `users` table 的帳號，但只有被標記為 Filament 使用者的人才能登入後台。你可以在 `AdminPanelProvider` 裡自訂登入邏輯。

### 建立第一個 Resource

Resource 是 Filament 的核心概念——每個 Resource 對應一個 Eloquent Model，自動生成 CRUD 介面：

```bash
php artisan make:filament-resource GroupBuy
```

這會在 `app/Filament/Resources/` 下建立一整組檔案：

```text
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
&lt;?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 = &apos;heroicon-o-shopping-bag&apos;;

    protected static ?string $navigationLabel = &apos;團購管理&apos;;

    protected static ?string $modelLabel = &apos;團購&apos;;

    public static function form(Form $form): Form
    {
        return $form
            -&gt;schema([
                Forms\Components\Section::make(&apos;基本資訊&apos;)
                    -&gt;schema([
                        Forms\Components\TextInput::make(&apos;title&apos;)
                            -&gt;label(&apos;團購標題&apos;)
                            -&gt;required()
                            -&gt;maxLength(255),

                        Forms\Components\Textarea::make(&apos;description&apos;)
                            -&gt;label(&apos;說明&apos;)
                            -&gt;rows(4)
                            -&gt;columnSpanFull(),

                        Forms\Components\Select::make(&apos;organizer_id&apos;)
                            -&gt;label(&apos;開團主&apos;)
                            -&gt;relationship(&apos;organizer&apos;, &apos;name&apos;)
                            -&gt;searchable()
                            -&gt;preload()
                            -&gt;required(),

                        Forms\Components\Select::make(&apos;status&apos;)
                            -&gt;label(&apos;狀態&apos;)
                            -&gt;options([
                                &apos;draft&apos;     =&gt; &apos;草稿&apos;,
                                &apos;open&apos;      =&gt; &apos;開團中&apos;,
                                &apos;closed&apos;    =&gt; &apos;已截止&apos;,
                                &apos;confirmed&apos; =&gt; &apos;已成團&apos;,
                                &apos;cancelled&apos; =&gt; &apos;已取消&apos;,
                            ])
                            -&gt;default(&apos;draft&apos;)
                            -&gt;required(),
                    ])-&gt;columns(2),

                Forms\Components\Section::make(&apos;時間與門檻&apos;)
                    -&gt;schema([
                        Forms\Components\DateTimePicker::make(&apos;starts_at&apos;)
                            -&gt;label(&apos;開始時間&apos;)
                            -&gt;required(),

                        Forms\Components\DateTimePicker::make(&apos;ends_at&apos;)
                            -&gt;label(&apos;截止時間&apos;)
                            -&gt;required()
                            -&gt;after(&apos;starts_at&apos;),

                        Forms\Components\TextInput::make(&apos;min_participants&apos;)
                            -&gt;label(&apos;最低成團人數&apos;)
                            -&gt;numeric()
                            -&gt;minValue(1)
                            -&gt;required(),

                        Forms\Components\TextInput::make(&apos;max_participants&apos;)
                            -&gt;label(&apos;人數上限&apos;)
                            -&gt;numeric()
                            -&gt;nullable(),
                    ])-&gt;columns(2),

                Forms\Components\Section::make(&apos;圖片&apos;)
                    -&gt;schema([
                        Forms\Components\FileUpload::make(&apos;image&apos;)
                            -&gt;label(&apos;封面圖&apos;)
                            -&gt;image()
                            -&gt;directory(&apos;group-buys&apos;)
                            -&gt;maxSize(2048),
                    ]),
            ]);
    }
```

幾個值得注意的設計：

- **`Section`** 把表單分成邏輯區塊，管理員不用面對一整片欄位
- **`Select::make()-&gt;relationship()`** 自動從關聯 Model 撈資料，還支援搜尋
- **`DateTimePicker::make()-&gt;after(&apos;starts_at&apos;)`** 內建驗證，截止時間必須晚於開始時間
- **`FileUpload`** 直接處理檔案上傳，不用自己寫 storage 邏輯

### Table Schema：定義列表

接續同一個 `GroupBuyResource` class：

```php
    public static function table(Table $table): Table
    {
        return $table
            -&gt;columns([
                Tables\Columns\TextColumn::make(&apos;title&apos;)
                    -&gt;label(&apos;標題&apos;)
                    -&gt;searchable()
                    -&gt;sortable()
                    -&gt;limit(30),

                Tables\Columns\TextColumn::make(&apos;organizer.name&apos;)
                    -&gt;label(&apos;開團主&apos;)
                    -&gt;searchable()
                    -&gt;sortable(),

                Tables\Columns\TextColumn::make(&apos;status&apos;)
                    -&gt;label(&apos;狀態&apos;)
                    -&gt;badge()
                    -&gt;color(fn (string $state): string =&gt; match ($state) {
                        &apos;draft&apos;     =&gt; &apos;gray&apos;,
                        &apos;open&apos;      =&gt; &apos;success&apos;,
                        &apos;closed&apos;    =&gt; &apos;warning&apos;,
                        &apos;confirmed&apos; =&gt; &apos;primary&apos;,
                        &apos;cancelled&apos; =&gt; &apos;danger&apos;,
                        default     =&gt; &apos;gray&apos;,
                    })
                    -&gt;formatStateUsing(fn (string $state): string =&gt; match ($state) {
                        &apos;draft&apos;     =&gt; &apos;草稿&apos;,
                        &apos;open&apos;      =&gt; &apos;開團中&apos;,
                        &apos;closed&apos;    =&gt; &apos;已截止&apos;,
                        &apos;confirmed&apos; =&gt; &apos;已成團&apos;,
                        &apos;cancelled&apos; =&gt; &apos;已取消&apos;,
                        default     =&gt; $state,
                    }),

                Tables\Columns\TextColumn::make(&apos;participants_count&apos;)
                    -&gt;label(&apos;參加人數&apos;)
                    -&gt;counts(&apos;participants&apos;)
                    -&gt;sortable(),

                Tables\Columns\TextColumn::make(&apos;ends_at&apos;)
                    -&gt;label(&apos;截止時間&apos;)
                    -&gt;dateTime(&apos;Y-m-d H:i&apos;)
                    -&gt;sortable(),

                Tables\Columns\TextColumn::make(&apos;created_at&apos;)
                    -&gt;label(&apos;建立時間&apos;)
                    -&gt;dateTime(&apos;Y-m-d&apos;)
                    -&gt;sortable()
                    -&gt;toggleable(isToggledHiddenByDefault: true),
            ])
            -&gt;filters([
                Tables\Filters\SelectFilter::make(&apos;status&apos;)
                    -&gt;label(&apos;狀態&apos;)
                    -&gt;options([
                        &apos;draft&apos;     =&gt; &apos;草稿&apos;,
                        &apos;open&apos;      =&gt; &apos;開團中&apos;,
                        &apos;closed&apos;    =&gt; &apos;已截止&apos;,
                        &apos;confirmed&apos; =&gt; &apos;已成團&apos;,
                        &apos;cancelled&apos; =&gt; &apos;已取消&apos;,
                    ]),

                Tables\Filters\Filter::make(&apos;active&apos;)
                    -&gt;label(&apos;進行中&apos;)
                    -&gt;query(fn ($query) =&gt; $query
                        -&gt;where(&apos;status&apos;, &apos;open&apos;)
                        -&gt;where(&apos;ends_at&apos;, &apos;&gt;&apos;, now())
                    ),
            ])
            -&gt;actions([
                Tables\Actions\EditAction::make(),
                Tables\Actions\ViewAction::make(),
            ])
            -&gt;bulkActions([
                Tables\Actions\BulkActionGroup::make([
                    Tables\Actions\DeleteBulkAction::make(),
                ]),
            ])
            -&gt;defaultSort(&apos;created_at&apos;, &apos;desc&apos;);
    }
```

這段程式碼產出的效果：一個有搜尋、排序、篩選、分頁的完整列表頁面。狀態欄位用 Badge 顯示彩色標籤，參加人數用 `counts()` 自動計算。如果用手寫的方式做這些功能，至少要花兩三天。

### 關聯管理

接續同一個 `GroupBuyResource` class：

```php
    public static function getRelations(): array
    {
        return [
            RelationManagers\OrdersRelationManager::class,
            RelationManagers\ParticipantsRelationManager::class,
        ];
    }
```

Relation Manager 讓你在團購的編輯頁面直接管理訂單和參加者——不用跳到其他頁面。建立方式：

```bash
php artisan make:filament-relation-manager GroupBuyResource orders amount
```

## Dashboard Widgets：資料視覺化

空白的 Dashboard 不太有用。Filament 提供了幾種內建 Widget，讓你快速建立數據儀表板。

### StatsOverview Widget

```bash
php artisan make:filament-widget GroupBuyStatsOverview --stats-overview
```

```php
&lt;?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(&apos;總團購數&apos;, GroupBuy::count())
                -&gt;description(&apos;所有團購&apos;)
                -&gt;descriptionIcon(&apos;heroicon-m-shopping-bag&apos;)
                -&gt;color(&apos;primary&apos;),

            Stat::make(&apos;進行中&apos;, GroupBuy::where(&apos;status&apos;, &apos;open&apos;)-&gt;count())
                -&gt;description(&apos;目前開放中的團購&apos;)
                -&gt;descriptionIcon(&apos;heroicon-m-arrow-trending-up&apos;)
                -&gt;color(&apos;success&apos;),

            Stat::make(
                &apos;本月營收&apos;,
                &apos;NT$ &apos; . number_format(
                    Order::whereMonth(&apos;created_at&apos;, now()-&gt;month)
                        -&gt;whereYear(&apos;created_at&apos;, now()-&gt;year)
                        -&gt;sum(&apos;amount&apos;) / 100
                )
            )
                -&gt;description(&apos;本月訂單總額&apos;)
                -&gt;descriptionIcon(&apos;heroicon-m-currency-dollar&apos;)
                -&gt;color(&apos;warning&apos;),
        ];
    }
}
```

三張統計卡片就出現在 Dashboard 頂端——總團購數、進行中的團購、本月營收。不用寫任何前端 JavaScript。

### Chart Widget

```bash
php artisan make:filament-widget GroupBuyChart --chart
```

```php
&lt;?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 = &apos;每週新增團購&apos;;

    protected static ?int $sort = 2;

    protected function getData(): array
    {
        $data = collect(range(7, 0))-&gt;map(function ($weeksAgo) {
            $start = now()-&gt;subWeeks($weeksAgo)-&gt;startOfWeek();
            $end = $start-&gt;copy()-&gt;endOfWeek();

            return [
                &apos;week&apos; =&gt; $start-&gt;format(&apos;m/d&apos;),
                &apos;count&apos; =&gt; GroupBuy::whereBetween(&apos;created_at&apos;, [$start, $end])-&gt;count(),
            ];
        });

        return [
            &apos;datasets&apos; =&gt; [
                [
                    &apos;label&apos; =&gt; &apos;新增團購&apos;,
                    &apos;data&apos; =&gt; $data-&gt;pluck(&apos;count&apos;)-&gt;toArray(),
                    &apos;borderColor&apos; =&gt; &apos;#6366f1&apos;,
                    &apos;backgroundColor&apos; =&gt; &apos;rgba(99, 102, 241, 0.1)&apos;,
                    &apos;fill&apos; =&gt; true,
                ],
            ],
            &apos;labels&apos; =&gt; $data-&gt;pluck(&apos;week&apos;)-&gt;toArray(),
        ];
    }

    protected function getType(): string
    {
        return &apos;line&apos;; // 支援 &apos;line&apos;, &apos;bar&apos;, &apos;pie&apos;, &apos;doughnut&apos; 等
    }
}
```

Widget 預設會出現在 Dashboard 頁面。你也可以用 `$sort` 屬性控制顯示順序——數字越小越上面。

### 自訂 Widget 範例

除了統計和圖表，你也可以做完全自訂的 Widget，例如顯示「最近即將截止的團購」：

```bash
php artisan make:filament-widget ExpiringGroupBuys
```

```php
&lt;?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 = &apos;即將截止的團購&apos;;

    protected static ?int $sort = 3;

    protected int | string | array $columnSpan = &apos;full&apos;;

    public function table(Table $table): Table
    {
        return $table
            -&gt;query(
                GroupBuy::where(&apos;status&apos;, &apos;open&apos;)
                    -&gt;where(&apos;ends_at&apos;, &apos;&lt;=&apos;, now()-&gt;addDays(3))
                    -&gt;where(&apos;ends_at&apos;, &apos;&gt;&apos;, now())
                    -&gt;orderBy(&apos;ends_at&apos;)
            )
            -&gt;columns([
                Tables\Columns\TextColumn::make(&apos;title&apos;)
                    -&gt;label(&apos;標題&apos;),
                Tables\Columns\TextColumn::make(&apos;organizer.name&apos;)
                    -&gt;label(&apos;開團主&apos;),
                Tables\Columns\TextColumn::make(&apos;ends_at&apos;)
                    -&gt;label(&apos;截止時間&apos;)
                    -&gt;dateTime(&apos;Y-m-d H:i&apos;)
                    -&gt;color(&apos;danger&apos;),
                Tables\Columns\TextColumn::make(&apos;participants_count&apos;)
                    -&gt;label(&apos;目前人數&apos;)
                    -&gt;counts(&apos;participants&apos;),
            ])
            -&gt;paginated(false);
    }
}
```

這個 Table Widget 直接在 Dashboard 上顯示一個迷你列表，讓管理員一眼掌握哪些團購快截止了。

## 進階 Eloquent：Eager Loading 解決 N+1

後台蓋好了，現在來處理效能問題。Eloquent 最常見、也最容易踩到的效能地雷就是 **N+1 查詢問題**。（Eloquent 關聯的基礎用法見[第四章](/blog/laravel-guide-eloquent-orm-models/)）

### 什麼是 N+1 問題

假設你要顯示「所有團購 + 每個團購的開團主名稱」：

```php
// ❌ 這段程式碼有 N+1 問題
$groupBuys = GroupBuy::all(); // 1 次查詢：SELECT * FROM group_buys

foreach ($groupBuys as $groupBuy) {
    echo $groupBuy-&gt;organizer-&gt;name;
    // 每次迴圈都會觸發一次查詢：
    // SELECT * FROM users WHERE id = ?
}
```

如果有 100 筆團購，這段程式碼會執行 **101 次查詢**（1 次取所有團購 + 100 次取開團主）。這就是 N+1 問題——1 次取清單、N 次取關聯。

你可能覺得 101 次查詢也還好？試試看 1000 筆團購——就是 1001 次查詢。再加上每個團購要顯示參加人數，那就是 2001 次。頁面直接從 200ms 變成 5 秒。

### Eager Loading：一次把關聯資料撈好

```php
// ✅ 用 with() 做 Eager Loading
$groupBuys = GroupBuy::with(&apos;organizer&apos;)-&gt;get();
// 只有 2 次查詢：
// SELECT * FROM group_buys
// SELECT * FROM users WHERE id IN (1, 2, 3, ..., 100)

foreach ($groupBuys as $groupBuy) {
    echo $groupBuy-&gt;organizer-&gt;name; // 不會再觸發查詢
}
```

`with(&apos;organizer&apos;)` 會一次把所有需要的 `users` 撈回來，用 `WHERE IN` 而不是一筆一筆查。從 101 次查詢變成 2 次。

你可以同時 Eager Load 多個關聯，甚至是巢狀關聯：

```php
// 多個關聯
$groupBuys = GroupBuy::with([&apos;organizer&apos;, &apos;participants&apos;, &apos;products&apos;])-&gt;get();

// 巢狀關聯：團購 → 訂單 → 訂單項目
$groupBuys = GroupBuy::with(&apos;orders.items&apos;)-&gt;get();

// 限制 Eager Loading 的欄位（節省記憶體）
$groupBuys = GroupBuy::with(&apos;organizer:id,name,email&apos;)-&gt;get();
```

### withCount：只要數量不要全部資料

有時你只需要知道「有幾個」，不需要把整個關聯載入：

```php
$groupBuys = GroupBuy::withCount(&apos;participants&apos;)-&gt;get();

foreach ($groupBuys as $groupBuy) {
    echo $groupBuy-&gt;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` 裡設定：

```php
// app/Providers/AppServiceProvider.php

use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    // 開發環境：禁止 Lazy Loading，直接拋出例外
    Model::preventLazyLoading(! app()-&gt;isProduction());
}
```

加了這行之後，任何沒有預先 Eager Load 的關聯存取都會直接拋出 `LazyLoadingViolationException`。等於是在開發階段強制你用 `with()` 載入所有需要的關聯。到了正式環境則自動關閉，避免意外中斷服務。

## Query Scopes：可重用的查詢條件

在揪好買裡，你會一直重複寫一些查詢條件：「只要開團中的」、「只要還沒截止的」、「只要某個開團主的」。與其每次都手寫 `where()` 鏈，不如把它們封裝成 Scope。

### Local Scopes

在 Model 裡定義 `scope` 開頭的方法：

```php
&lt;?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-&gt;where(&apos;status&apos;, &apos;open&apos;)
              -&gt;where(&apos;ends_at&apos;, &apos;&gt;&apos;, now());
    }

    /**
     * 篩選：已截止（超過截止時間或被手動關閉）
     */
    public function scopeExpired(Builder $query): void
    {
        $query-&gt;where(function ($q) {
            $q-&gt;where(&apos;ends_at&apos;, &apos;&lt;=&apos;, now())
              -&gt;orWhere(&apos;status&apos;, &apos;closed&apos;);
        });
    }

    /**
     * 篩選：特定開團主的團購
     */
    public function scopeByOrganizer(Builder $query, int $userId): void
    {
        $query-&gt;where(&apos;organizer_id&apos;, $userId);
    }

    /**
     * 篩選：已達成團門檻
     */
    public function scopeReachedMinimum(Builder $query): void
    {
        $query-&gt;whereColumn(
            &apos;participants_count&apos;, &apos;&gt;=&apos;, &apos;min_participants&apos;
        );
    }
}
```

使用起來非常乾淨，而且可以鏈式組合：

```php
// 取得所有進行中的團購
$openGroupBuys = GroupBuy::open()-&gt;get();

// 某個使用者的進行中團購
$myOpenGroupBuys = GroupBuy::open()
    -&gt;byOrganizer(auth()-&gt;id())
    -&gt;get();

// 已截止但還沒成團的（可能需要退款）
$expiredNotConfirmed = GroupBuy::expired()
    -&gt;where(&apos;status&apos;, &apos;!=&apos;, &apos;confirmed&apos;)
    -&gt;get();

// 搭配 Eager Loading
$openWithDetails = GroupBuy::open()
    -&gt;with([&apos;organizer&apos;, &apos;products&apos;])
    -&gt;withCount(&apos;participants&apos;)
    -&gt;latest()
    -&gt;paginate(20);
```

Scope 的好處是**查詢邏輯只定義一次**。如果哪天「開團中」的定義改了（例如加了「需要通過審核」這個條件），你只需要改 `scopeOpen()` 一個地方，所有使用它的地方自動更新。

### Global Scopes

Global Scope 會自動套用在所有查詢上，最常見的用途是軟刪除（`SoftDeletes` trait 就是透過 Global Scope 實現的）。你也可以自訂：

```php
// 自動排除被封鎖的開團主的團購
use Illuminate\Database\Eloquent\Scope;

class ExcludeBannedOrganizersScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        $builder-&gt;whereHas(&apos;organizer&apos;, fn ($q) =&gt; $q-&gt;where(&apos;is_banned&apos;, false));
    }
}
```

Global Scope 很強大，但也很容易造成「為什麼查不到那筆資料？」的困惑。建議只在非常確定的情境下使用，大多數時候 Local Scope 就夠了。

## Subqueries：複雜報表查詢

有些報表需求光靠 `with()` 和 `withCount()` 搞不定——例如「每個開團主的累計營收排行」。這時候就需要子查詢。

### addSelect + Subquery

```php
use App\Models\User;
use App\Models\Order;
use Illuminate\Database\Eloquent\Builder;

// 每個開團主的累計營收
$organizers = User::query()
    -&gt;addSelect([
        &apos;total_revenue&apos; =&gt; Order::query()
            -&gt;selectRaw(&apos;SUM(amount)&apos;)
            -&gt;whereColumn(&apos;orders.group_buy_id&apos;, &apos;group_buys.id&apos;)
            -&gt;whereIn(&apos;orders.group_buy_id&apos;, function ($query) {
                $query-&gt;select(&apos;id&apos;)
                    -&gt;from(&apos;group_buys&apos;)
                    -&gt;whereColumn(&apos;group_buys.organizer_id&apos;, &apos;users.id&apos;);
            }),
    ])
    -&gt;addSelect([
        &apos;group_buy_count&apos; =&gt; GroupBuy::query()
            -&gt;selectRaw(&apos;COUNT(*)&apos;)
            -&gt;whereColumn(&apos;group_buys.organizer_id&apos;, &apos;users.id&apos;),
    ])
    -&gt;having(&apos;group_buy_count&apos;, &apos;&gt;&apos;, 0)
    -&gt;orderByDesc(&apos;total_revenue&apos;)
    -&gt;get();
```

這段查詢在 SQL 層面就算好了每個開團主的營收和開團數，不需要在 PHP 端做 N 次迴圈計算。

### 簡化版：用 withSum

Laravel 其實提供了更簡潔的語法處理常見的聚合需求：

```php
// 每個團購的總訂單金額
$groupBuys = GroupBuy::withSum(&apos;orders&apos;, &apos;amount&apos;)
    -&gt;withCount(&apos;participants&apos;)
    -&gt;orderByDesc(&apos;orders_sum_amount&apos;)
    -&gt;get();

foreach ($groupBuys as $groupBuy) {
    echo $groupBuy-&gt;orders_sum_amount;  // 自動命名：{relation}_sum_{column}
    echo $groupBuy-&gt;participants_count;
}
```

`withSum()`、`withAvg()`、`withMin()`、`withMax()` 這幾個方法底層都是用子查詢實現的，語法上比手寫 `addSelect()` 簡潔得多。

### 什麼時候用 Raw SQL

Query Builder 能處理 90% 的需求，但偶爾你會遇到非常複雜的報表查詢——多層 JOIN、窗口函數、CTE（Common Table Expression）。這時候不要硬用 Query Builder，直接寫 Raw SQL 反而更清楚：

```php
use Illuminate\Support\Facades\DB;

// 每週營收趨勢（用窗口函數計算移動平均）
$weeklyRevenue = DB::select(&quot;
    SELECT
        DATE_FORMAT(created_at, &apos;%Y-%u&apos;) AS week,
        SUM(amount) AS revenue,
        AVG(SUM(amount)) OVER (
            ORDER BY DATE_FORMAT(created_at, &apos;%Y-%u&apos;)
            ROWS BETWEEN 3 PRECEDING AND CURRENT ROW
        ) AS moving_avg
    FROM orders
    WHERE created_at &gt;= ?
    GROUP BY week
    ORDER BY week
&quot;, [now()-&gt;subMonths(3)]);
```

經驗法則：**如果你花超過 10 分鐘在組 Query Builder 鏈，而且結果還不太對，那就直接寫 SQL。** 可讀性比「全部用 Eloquent」更重要。Raw SQL 的缺點是失去了資料庫引擎抽象（SQLite 和 MySQL 語法不完全相同），但在報表查詢這種場景，你通常只會在一種資料庫上跑。

## Laravel Debugbar：效能偵測利器

前面講了這麼多最佳化技巧，但怎麼確認真的有效？靠感覺不行——你需要實際看到查詢數量和執行時間。Laravel Debugbar 就是幹這個的。

### 安裝

```bash
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(&apos;organizer&apos;)`）：**

- Queries: 2 queries
- Time: 45ms
- Memory: 8 MB

差距一目了然。養成習慣：每次寫完一個頁面，開 Debugbar 看一眼查詢數量。超過 10 次的，八成有 N+1 可以修。

&gt; **提醒：** Debugbar 絕對不能部署到正式環境。它會暴露你的 SQL 查詢、環境變數、路由結構——等於是把所有內部資訊攤開給攻擊者看。`--dev` 裝、`APP_DEBUG=false` 就會自動隱藏，但還是建議在部署前確認一下。

## 實作：揪好買的開團主與管理員後台

到目前為止我們學了 Filament 和進階查詢的個別技巧，現在把它們串起來，為揪好買建立完整的後台系統。

### 管理員後台：完整 Resource 體系

管理員需要管理三個核心資源：團購、訂單、使用者。

```bash
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
&lt;?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
            -&gt;default()
            -&gt;id(&apos;admin&apos;)
            -&gt;path(&apos;admin&apos;)
            -&gt;login()
            -&gt;colors([
                &apos;primary&apos; =&gt; Color::Indigo,
            ])
            -&gt;discoverResources(
                in: app_path(&apos;Filament/Resources&apos;),
                for: &apos;App\\Filament\\Resources&apos;
            )
            -&gt;discoverPages(
                in: app_path(&apos;Filament/Pages&apos;),
                for: &apos;App\\Filament\\Pages&apos;
            )
            -&gt;widgets([
                GroupBuyStatsOverview::class,
                GroupBuyChart::class,
                ExpiringGroupBuys::class,
            ])
            -&gt;middleware([
                // ...預設 middleware
            ])
            -&gt;authMiddleware([
                // 確保只有管理員能存取
            ]);
    }
}
```

### 開團主儀表板：第二個 Panel

揪好買的開團主不是管理員，但他們需要看到自己的開團統計、管理自己的團購。Filament 5 支援**多 Panel**——你可以為不同角色建立不同的後台。

```bash
php artisan make:filament-panel organizer
```

這會建立 `app/Providers/Filament/OrganizerPanelProvider.php`：

```php
&lt;?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
            -&gt;id(&apos;organizer&apos;)
            -&gt;path(&apos;organizer&apos;)       // 路由前綴：/organizer
            -&gt;login()
            -&gt;colors([
                &apos;primary&apos; =&gt; Color::Emerald,  // 用不同主色區分
            ])
            -&gt;discoverResources(
                in: app_path(&apos;Filament/Organizer/Resources&apos;),
                for: &apos;App\\Filament\\Organizer\\Resources&apos;
            )
            -&gt;discoverPages(
                in: app_path(&apos;Filament/Organizer/Pages&apos;),
                for: &apos;App\\Filament\\Organizer\\Pages&apos;
            );
    }
}
```

接著為開團主建立專屬的 Resource，只顯示自己的資料：

```php
&lt;?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 = &apos;我的團購&apos;;

    protected static ?string $modelLabel = &apos;團購&apos;;

    // 關鍵：只查自己的團購
    public static function getEloquentQuery(): Builder
    {
        return parent::getEloquentQuery()
            -&gt;byOrganizer(auth()-&gt;id())  // 用前面定義的 scope
            -&gt;withCount(&apos;participants&apos;)
            -&gt;withSum(&apos;orders&apos;, &apos;amount&apos;);
    }

    public static function table(Table $table): Table
    {
        return $table
            -&gt;columns([
                Tables\Columns\TextColumn::make(&apos;title&apos;)
                    -&gt;label(&apos;標題&apos;)
                    -&gt;searchable(),

                Tables\Columns\TextColumn::make(&apos;status&apos;)
                    -&gt;label(&apos;狀態&apos;)
                    -&gt;badge()
                    -&gt;color(fn (string $state): string =&gt; match ($state) {
                        &apos;draft&apos;     =&gt; &apos;gray&apos;,
                        &apos;open&apos;      =&gt; &apos;success&apos;,
                        &apos;closed&apos;    =&gt; &apos;warning&apos;,
                        &apos;confirmed&apos; =&gt; &apos;primary&apos;,
                        default     =&gt; &apos;gray&apos;,
                    }),

                Tables\Columns\TextColumn::make(&apos;participants_count&apos;)
                    -&gt;label(&apos;參加人數&apos;)
                    -&gt;sortable(),

                Tables\Columns\TextColumn::make(&apos;orders_sum_amount&apos;)
                    -&gt;label(&apos;訂單總額&apos;)
                    -&gt;money(&apos;TWD&apos;)
                    -&gt;sortable(),

                Tables\Columns\TextColumn::make(&apos;ends_at&apos;)
                    -&gt;label(&apos;截止時間&apos;)
                    -&gt;dateTime(&apos;Y-m-d H:i&apos;),
            ])
            -&gt;defaultSort(&apos;created_at&apos;, &apos;desc&apos;);
    }

    // ...form() 和其他設定
}
```

注意 `getEloquentQuery()` 的覆寫——這是 Filament 的資料隔離機制。開團主只看得到自己的團購，就算手動改 URL 中的 ID 也拿不到別人的資料。搭配前面定義的 `scopeByOrganizer()`，query 條件清楚又好維護。

### 自訂 Dashboard 頁面

開團主的 Dashboard 跟管理員不同——他們更關心自己的數據。你可以建立自訂頁面：

```bash
php artisan make:filament-page OrganizerDashboard --panel=organizer
```

```php
&lt;?php

namespace App\Filament\Organizer\Pages;

use App\Models\GroupBuy;
use Filament\Pages\Page;

class OrganizerDashboard extends Page
{
    protected static ?string $navigationIcon = &apos;heroicon-o-home&apos;;

    protected static ?string $title = &apos;我的儀表板&apos;;

    protected static string $view = &apos;filament.organizer.pages.organizer-dashboard&apos;;

    protected static ?int $navigationSort = -2;

    public function getViewData(): array
    {
        $userId = auth()-&gt;id();

        return [
            &apos;totalGroupBuys&apos; =&gt; GroupBuy::byOrganizer($userId)-&gt;count(),
            &apos;activeGroupBuys&apos; =&gt; GroupBuy::byOrganizer($userId)-&gt;open()-&gt;count(),
            &apos;totalRevenue&apos; =&gt; GroupBuy::byOrganizer($userId)
                -&gt;withSum(&apos;orders&apos;, &apos;amount&apos;)
                -&gt;get()
                -&gt;sum(&apos;orders_sum_amount&apos;),
            &apos;recentGroupBuys&apos; =&gt; GroupBuy::byOrganizer($userId)
                -&gt;with(&apos;participants&apos;)
                -&gt;withCount(&apos;participants&apos;)
                -&gt;latest()
                -&gt;take(5)
                -&gt;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 程式」**](/blog/laravel-guide-testing-pest-ci/)會教你用 Pest 為核心流程寫完整測試，搭配 GitHub Actions 讓每次 Push 都自動驗證——從此你可以安心重構，不怕改一處壞三處。</content:encoded><media:content url="https://bobochen.dev/_astro/cover.DZRp5ULp.webp" medium="image"/><category>PHP</category><category>Laravel</category><category>Filament</category><category>Admin Panel</category><category>Eloquent</category><category>N+1</category><enclosure url="https://bobochen.dev/_astro/cover.DZRp5ULp.webp" length="0" type="image/png"/></item><item><title>RESTful API 與 Sanctum：讓 LINE Bot 也能開團</title><link>https://bobochen.dev/blog/laravel-guide-api-sanctum-rest/</link><guid isPermaLink="true">https://bobochen.dev/blog/laravel-guide-api-sanctum-rest/</guid><description>用 Laravel 12 打造 RESTful API、以 Sanctum Token 認證保護端點，加上 Rate Limiting、API 版本管理與 CORS 設定，讓揪好買同時服務手機 App、LINE Bot 與第三方整合。</description><pubDate>Tue, 13 May 2025 00:00:00 GMT</pubDate><content:encoded>你的網頁應用做得很好，使用者可以開團、跟團、下單。但現實世界不只有瀏覽器。有人想用手機 App 開團，有人想在 LINE 群組裡直接查詢團購狀態，還有合作店家想透過程式自動同步訂單資料。這些需求都指向同一件事：你需要 API。

RESTful API 是讓你的後端從「只服務網頁」進化到「服務所有人」的關鍵。Laravel 這部分該有的都有——API Routes、API Resources、Sanctum Token 認證。你不需要另外架一套 API Server，同一個 Laravel 專案就能同時服務網頁和 API 客戶端。

這一章我們要把揪好買的核心功能——開團列表、跟團操作、訂單查詢——全部包裝成 RESTful API，用 Sanctum 做 Token 認證保護端點，設定 Rate Limiting 防止濫用，還會示範 LINE Bot 整合的概念。一套後端程式碼，同時餵給多個前端消費者。

## 為什麼需要 API：不只是網頁的世界

前十章我們做的事情，都是「使用者打開瀏覽器 → 伺服器回傳 HTML」。這在桌面端的體驗很好，但想想這些場景：

- **手機 App**：你的合作夥伴想做一個揪好買的 iOS/Android App，它不需要 HTML，它需要 JSON 資料
- **LINE Bot**：台灣人都在用 LINE，如果在群組裡打「@揪好買 查團購」就能看到最新團購列表，多方便？
- **第三方整合**：合作店家的 ERP 系統想自動抓訂單資料，它不會開瀏覽器，它用程式 call API
- **SPA 前端**：如果你的前端團隊想用 React 或 Vue 重寫介面，它們只需要跟 API 溝通
- **自動化腳本**：你自己想寫一個 cron job 在截止時間自動關閉團購，用 API 最乾淨

這些消費者有一個共通點：它們不需要 HTML，它們需要**結構化的資料**。而 JSON 就是這個通用語言。

```
瀏覽器使用者 → routes/web.php → 回傳 HTML（Blade view）
手機 App     → routes/api.php → 回傳 JSON
LINE Bot     → routes/api.php → 回傳 JSON
第三方系統   → routes/api.php → 回傳 JSON
```

API 不是什麼高深的東西——它就是你的後端用 JSON 格式說話，讓任何程式都能聽懂。

## API Routes：api.php 與 web.php 的差異

### 安裝 API 路由

重要的一點：**Laravel 12 預設不包含 `routes/api.php`**。這是刻意的設計——不是每個專案都需要 API。要啟用它，跑一行指令：

```bash
php artisan install:api
```

這個指令幫你做了三件事：

1. 建立 `routes/api.php` 檔案
2. 安裝 **Laravel Sanctum**（Token 認證套件）
3. 執行 Sanctum 需要的 migration（`personal_access_tokens` 表）

裝完後你會在 `routes/api.php` 裡看到這樣的起始內容：

```php
&lt;?php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::get(&apos;/user&apos;, function (Request $request) {
    return $request-&gt;user();
})-&gt;middleware(&apos;auth:sanctum&apos;);
```

### web.php vs api.php：兩個世界

這兩個路由檔案的差異不只是「放在不同檔案」而已，它們跑在完全不同的 middleware 環境裡：

| 特性 | web.php | api.php |
|------|---------|---------|
| URL 前綴 | 無（`/group-buys`） | `/api`（`/api/group-buys`） |
| Session | 有（記住登入狀態） | 無（Stateless） |
| CSRF 保護 | 有（`@csrf`） | 無（不需要） |
| Cookie | 有 | 無 |
| 認證方式 | Session-based（瀏覽器） | Token-based（Sanctum） |
| Rate Limiting | 無預設 | `throttle:api`（每分鐘 60 次） |
| 回傳格式 | HTML（Blade view） | JSON |

關鍵差異是**stateless**：每個 API request 都是獨立的，伺服器不會「記得」你是誰。每次請求都要帶著 Token 來證明身份。這就像去便利商店——你每次都要出示會員卡，店員不會記得你昨天來過。

### 定義 API 路由

```php
// routes/api.php
use App\Http\Controllers\Api\GroupBuyController;

// 公開端點——任何人都能呼叫
Route::get(&apos;/group-buys&apos;, [GroupBuyController::class, &apos;index&apos;]);
Route::get(&apos;/group-buys/{groupBuy}&apos;, [GroupBuyController::class, &apos;show&apos;]);

// 受保護端點——需要 Sanctum Token
Route::middleware(&apos;auth:sanctum&apos;)-&gt;group(function () {
    Route::post(&apos;/group-buys&apos;, [GroupBuyController::class, &apos;store&apos;]);
    Route::post(&apos;/group-buys/{groupBuy}/join&apos;, [GroupBuyController::class, &apos;join&apos;]);
    Route::get(&apos;/my/orders&apos;, [GroupBuyController::class, &apos;myOrders&apos;]);
});
```

注意我把 API Controller 放在 `App\Http\Controllers\Api\` 命名空間下——跟 web Controller 分開，讓結構清楚。

```bash
php artisan make:controller Api/GroupBuyController --api
```

`--api` flag 會生成只有 `index`、`store`、`show`、`update`、`destroy` 五個方法的 Controller（沒有 `create` 和 `edit`，因為 API 不需要「表單頁面」）。

## API Resource：優雅的 JSON 轉換

直接在 Controller 裡 `return $groupBuy;` 可以嗎？技術上可以，但你會把整個 Model——包括 `created_at`、`updated_at`、甚至 `password`——全部暴露出去。API Resource 讓你精準控制「回傳哪些欄位、長什麼格式」。

### 建立 Resource

```bash
php artisan make:resource GroupBuyResource
```

```php
&lt;?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class GroupBuyResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            &apos;id&apos;           =&gt; $this-&gt;id,
            &apos;title&apos;        =&gt; $this-&gt;title,
            &apos;description&apos;  =&gt; $this-&gt;description,
            &apos;target_amount&apos;=&gt; $this-&gt;target_amount,
            &apos;current_amount&apos;=&gt; $this-&gt;current_amount,
            &apos;status&apos;       =&gt; $this-&gt;status-&gt;value,
            &apos;deadline&apos;     =&gt; $this-&gt;deadline-&gt;toIso8601String(),
            &apos;organizer&apos;    =&gt; [
                &apos;id&apos;   =&gt; $this-&gt;user-&gt;id,
                &apos;name&apos; =&gt; $this-&gt;user-&gt;name,
            ],
            &apos;participants_count&apos; =&gt; $this-&gt;participants_count,
            &apos;created_at&apos;   =&gt; $this-&gt;created_at-&gt;toIso8601String(),
        ];
    }
}
```

注意幾件事：

- **只暴露需要的欄位**——`user_id` 換成巢狀的 `organizer` 物件，更直覺
- **日期用 ISO 8601 格式**——`2026-08-10T08:00:00+08:00`，前端好 parse
- **不暴露 `updated_at`**——API 消費者不需要這個
- **Enum 轉成字串**——`$this-&gt;status-&gt;value` 而不是整個 Enum 物件

### 在 Controller 裡使用

```php
use App\Http\Resources\GroupBuyResource;

class GroupBuyController extends Controller
{
    public function index()
    {
        $groupBuys = GroupBuy::withCount(&apos;participants&apos;)
            -&gt;where(&apos;status&apos;, &apos;open&apos;)
            -&gt;latest()
            -&gt;paginate(15);

        return GroupBuyResource::collection($groupBuys);
    }

    public function show(GroupBuy $groupBuy)
    {
        $groupBuy-&gt;loadCount(&apos;participants&apos;);

        return new GroupBuyResource($groupBuy);
    }
}
```

### 回傳的 JSON 長這樣

單一資源：

```json
{
    &quot;data&quot;: {
        &quot;id&quot;: 42,
        &quot;title&quot;: &quot;芒果季團購&quot;,
        &quot;description&quot;: &quot;玉井愛文芒果，產地直送&quot;,
        &quot;target_amount&quot;: 30,
        &quot;current_amount&quot;: 18,
        &quot;status&quot;: &quot;open&quot;,
        &quot;deadline&quot;: &quot;2026-08-15T23:59:59+08:00&quot;,
        &quot;organizer&quot;: {
            &quot;id&quot;: 7,
            &quot;name&quot;: &quot;小陳&quot;
        },
        &quot;participants_count&quot;: 18,
        &quot;created_at&quot;: &quot;2026-08-01T10:30:00+08:00&quot;
    }
}
```

列表（搭配 Pagination）：

```json
{
    &quot;data&quot;: [
        { &quot;id&quot;: 42, &quot;title&quot;: &quot;芒果季團購&quot;, ... },
        { &quot;id&quot;: 41, &quot;title&quot;: &quot;阿里山烏龍茶&quot;, ... }
    ],
    &quot;links&quot;: {
        &quot;first&quot;: &quot;http://localhost/api/group-buys?page=1&quot;,
        &quot;last&quot;: &quot;http://localhost/api/group-buys?page=5&quot;,
        &quot;prev&quot;: null,
        &quot;next&quot;: &quot;http://localhost/api/group-buys?page=2&quot;
    },
    &quot;meta&quot;: {
        &quot;current_page&quot;: 1,
        &quot;last_page&quot;: 5,
        &quot;per_page&quot;: 15,
        &quot;total&quot;: 67
    }
}
```

Laravel 自動把 `paginate()` 的結果包成帶 `links` 和 `meta` 的結構——前端不用自己算分頁。

### ResourceCollection：集合層級的客製化

如果你想在集合回傳時加額外資訊（比如統計數據），可以建一個 Collection class：

```bash
php artisan make:resource GroupBuyCollection
```

```php
class GroupBuyCollection extends ResourceCollection
{
    public function toArray(Request $request): array
    {
        return [
            &apos;data&apos; =&gt; $this-&gt;collection,
            &apos;stats&apos; =&gt; [
                &apos;total_open&apos; =&gt; GroupBuy::where(&apos;status&apos;, &apos;open&apos;)-&gt;count(),
            ],
        ];
    }
}
```

&gt; **安全提醒：** Resource 是你 API 的門面。永遠不要暴露 `password`、`remember_token`、內部的 `pivot` 資料，或任何使用者不該看到的欄位。養成習慣——在 `toArray()` 裡明確列出每一個欄位，而不是用 `parent::toArray()` 全部丟出去。

## Sanctum Token Authentication

[第六章我們用 Session 做瀏覽器的認證](/blog/laravel-guide-auth-breeze-authorization/)。但 API 客戶端（手機 App、LINE Bot、第三方系統）不用瀏覽器，沒有 Cookie 和 Session。它們需要的是 **Token-based 認證**：每次請求都在 Header 帶一個 token，伺服器驗證這個 token 來識別身份。

### Token 認證的流程

```
1. 使用者用帳號密碼呼叫 /api/login
2. 伺服器驗證成功，回傳一個 token
3. 之後的每個請求，在 Header 帶上：Authorization: Bearer &lt;token&gt;
4. 伺服器看到 token，就知道你是誰
5. 登出時，伺服器把 token 刪掉
```

### 建立登入端點

```php
// routes/api.php
use App\Http\Controllers\Api\AuthController;

Route::post(&apos;/register&apos;, [AuthController::class, &apos;register&apos;]);
Route::post(&apos;/login&apos;, [AuthController::class, &apos;login&apos;]);

Route::middleware(&apos;auth:sanctum&apos;)-&gt;group(function () {
    Route::post(&apos;/logout&apos;, [AuthController::class, &apos;logout&apos;]);
    Route::get(&apos;/user&apos;, [AuthController::class, &apos;user&apos;]);
});
```

```php
&lt;?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;

class AuthController extends Controller
{
    public function register(Request $request)
    {
        $validated = $request-&gt;validate([
            &apos;name&apos;     =&gt; &apos;required|string|max:255&apos;,
            &apos;email&apos;    =&gt; &apos;required|string|email|unique:users&apos;,
            &apos;password&apos; =&gt; &apos;required|string|min:8|confirmed&apos;,
        ]);

        $user = User::create([
            &apos;name&apos;     =&gt; $validated[&apos;name&apos;],
            &apos;email&apos;    =&gt; $validated[&apos;email&apos;],
            &apos;password&apos; =&gt; Hash::make($validated[&apos;password&apos;]),
        ]);

        $token = $user-&gt;createToken(&apos;api-token&apos;)-&gt;plainTextToken;

        return response()-&gt;json([
            &apos;user&apos;  =&gt; $user-&gt;only(&apos;id&apos;, &apos;name&apos;, &apos;email&apos;),
            &apos;token&apos; =&gt; $token,
        ], 201);
    }

    public function login(Request $request)
    {
        $request-&gt;validate([
            &apos;email&apos;    =&gt; &apos;required|email&apos;,
            &apos;password&apos; =&gt; &apos;required&apos;,
        ]);

        $user = User::where(&apos;email&apos;, $request-&gt;email)-&gt;first();

        if (! $user || ! Hash::check($request-&gt;password, $user-&gt;password)) {
            throw ValidationException::withMessages([
                &apos;email&apos; =&gt; [&apos;帳號或密碼錯誤。&apos;],
            ]);
        }

        $token = $user-&gt;createToken(&apos;api-token&apos;)-&gt;plainTextToken;

        return response()-&gt;json([
            &apos;user&apos;  =&gt; $user-&gt;only(&apos;id&apos;, &apos;name&apos;, &apos;email&apos;),
            &apos;token&apos; =&gt; $token,
        ]);
    }

    public function logout(Request $request)
    {
        // 刪除當前使用的 token
        $request-&gt;user()-&gt;currentAccessToken()-&gt;delete();

        return response()-&gt;json([&apos;message&apos; =&gt; &apos;已登出&apos;]);
    }

    public function user(Request $request)
    {
        return response()-&gt;json($request-&gt;user()-&gt;only(&apos;id&apos;, &apos;name&apos;, &apos;email&apos;, &apos;role&apos;));
    }
}
```

`createToken()` 回傳的 `plainTextToken` 長這樣：`1|abc123def456...`，前面的數字是 token ID，後面是實際的 token 值。**這個值只會在建立時出現一次**——資料庫裡存的是 hash 過的版本。告訴你的 API 消費者：拿到 token 就要存好。

### Token Abilities（權限）

不是每個 token 都應該有全部權限。例如，LINE Bot 只需要「讀取」的權限，不該能刪除團購：

```php
// 建立有限權限的 token
$token = $user-&gt;createToken(&apos;line-bot&apos;, [&apos;group-buys:read&apos;]);

// 建立完整權限的 token
$token = $user-&gt;createToken(&apos;mobile-app&apos;, [&apos;*&apos;]);
```

在路由裡檢查 ability：

```php
Route::middleware([&apos;auth:sanctum&apos;, &apos;ability:group-buys:read&apos;])-&gt;group(function () {
    Route::get(&apos;/group-buys&apos;, [GroupBuyController::class, &apos;index&apos;]);
    Route::get(&apos;/group-buys/{groupBuy}&apos;, [GroupBuyController::class, &apos;show&apos;]);
});

Route::middleware([&apos;auth:sanctum&apos;, &apos;ability:group-buys:write&apos;])-&gt;group(function () {
    Route::post(&apos;/group-buys&apos;, [GroupBuyController::class, &apos;store&apos;]);
    Route::delete(&apos;/group-buys/{groupBuy}&apos;, [GroupBuyController::class, &apos;destroy&apos;]);
});
```

需要在 `bootstrap/app.php` 裡註冊 ability middleware：

```php
use Laravel\Sanctum\Http\Middleware\CheckAbilities;
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;

-&gt;withMiddleware(function (Middleware $middleware) {
    $middleware-&gt;alias([
        &apos;abilities&apos; =&gt; CheckAbilities::class,   // 必須擁有「全部」列出的 abilities
        &apos;ability&apos;   =&gt; CheckForAnyAbility::class, // 擁有「其中一個」就通過
    ]);
})
```

### 撤銷 Token

```php
// 撤銷當前 token
$request-&gt;user()-&gt;currentAccessToken()-&gt;delete();

// 撤銷所有 token（強制所有裝置登出）
$request-&gt;user()-&gt;tokens()-&gt;delete();

// 撤銷特定 token
$request-&gt;user()-&gt;tokens()-&gt;where(&apos;id&apos;, $tokenId)-&gt;delete();
```

### Sanctum vs JWT

你可能聽過 JWT（JSON Web Token）。在 Laravel 生態系裡，Sanctum 和 JWT 的差異是：

| 特性 | Sanctum | JWT（tymon/jwt-auth） |
|------|---------|----------------------|
| 官方維護 | 是（Laravel 團隊） | 否（社群套件） |
| Token 儲存 | 資料庫 | 無狀態（Token 自帶資訊） |
| 撤銷 Token | 簡單（刪資料庫記錄） | 複雜（需要黑名單機制） |
| 適用場景 | SPA、手機 App、第三方 | 微服務間通訊 |
| 複雜度 | 低 | 中高 |
| 建議 | 90% 的專案用這個 | 除非有跨服務需求 |

除非你在做微服務架構，否則 Sanctum 就是正確的選擇。官方維護、設定簡單、能撤銷 token，沒什麼理由用 JWT。

## Rate Limiting：保護你的 API

公開的 API 如果沒有速率限制，一個寫壞的爬蟲或惡意攻擊就能把你的伺服器打掛。Rate Limiting 限制每個使用者（或 IP）在一段時間內能發多少請求。

### 預設設定

`php artisan install:api` 安裝後，`api.php` 的路由自動帶 `throttle:api` middleware。預設限制在 `bootstrap/app.php` 裡：

```php
-&gt;withMiddleware(function (Middleware $middleware) {
    $middleware-&gt;throttleApi();
    // 等同於 throttle:api，預設 60 次/分鐘
})
```

### 自訂 Rate Limiter

在 `AppServiceProvider` 的 `boot()` 方法裡定義：

```php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Http\Request;

public function boot(): void
{
    // 已登入使用者：每分鐘 120 次
    // 未登入（靠 IP）：每分鐘 30 次
    RateLimiter::for(&apos;api&apos;, function (Request $request) {
        return $request-&gt;user()
            ? Limit::perMinute(120)-&gt;by($request-&gt;user()-&gt;id)
            : Limit::perMinute(30)-&gt;by($request-&gt;ip());
    });

    // 登入端點特別嚴格：每分鐘 5 次（防暴力破解）
    RateLimiter::for(&apos;login&apos;, function (Request $request) {
        return Limit::perMinute(5)-&gt;by($request-&gt;ip());
    });
}
```

套用到路由：

```php
Route::post(&apos;/login&apos;, [AuthController::class, &apos;login&apos;])
    -&gt;middleware(&apos;throttle:login&apos;);
```

### Rate Limit Response Headers

Laravel 自動在回傳的 HTTP Header 裡告訴客戶端剩餘額度：

```
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
Retry-After: 58          ← 超過限制時，告訴你幾秒後可以再試
```

### 自訂超過限制時的回應

```php
RateLimiter::for(&apos;api&apos;, function (Request $request) {
    return Limit::perMinute(60)
        -&gt;by($request-&gt;user()?-&gt;id ?: $request-&gt;ip())
        -&gt;response(function (Request $request, array $headers) {
            return response()-&gt;json([
                &apos;message&apos; =&gt; &apos;請求太頻繁，請稍後再試。&apos;,
            ], 429, $headers);
        });
});
```

## API 版本管理策略

API 一旦發布出去，就有人在用。你改了回傳格式，人家的 App 就壞了。版本管理讓你能安全地升級 API 而不影響現有客戶端。

### 方法一：URL 前綴（最常見）

```php
// routes/api.php

// v1 版本
Route::prefix(&apos;v1&apos;)-&gt;group(function () {
    Route::get(&apos;/group-buys&apos;, [V1\GroupBuyController::class, &apos;index&apos;]);
});

// v2 版本（未來）
Route::prefix(&apos;v2&apos;)-&gt;group(function () {
    Route::get(&apos;/group-buys&apos;, [V2\GroupBuyController::class, &apos;index&apos;]);
});
```

呼叫方式：`GET /api/v1/group-buys`

### 方法二：Header 版本控制

```
GET /api/group-buys
Accept: application/vnd.jiuhaomai.v1+json
```

比較少見，但更「正統」。

### 務實的建議

**不要過早加版本號**。如果你的 API 還在開發階段、消費者只有自己的團隊，直接用 `/api/group-buys` 就好。等到以下情況才加 versioning：

- 有外部第三方在用你的 API
- 你需要做 breaking change（欄位改名、移除、格式變更）
- 你需要同時維護新舊版本

加版本號的成本是真實的：你要維護兩份 Controller、兩份 Resource、兩份文件。YAGNI（You Ain&apos;t Gonna Need It）——等需要的時候再加。

## API 文件化：Scramble

好的 API 沒有文件等於不存在。你的 API 消費者不該來看你的程式碼才知道怎麼呼叫端點。Scramble 是一個能直接從 Laravel 程式碼自動生成 OpenAPI（Swagger）文件的套件——不需要寫任何註解或額外設定。

### 安裝

```bash
composer require dedoc/scramble
```

就這樣。打開瀏覽器：

```
http://localhost:8000/docs/api
```

你會看到一個互動式的 API 文件頁面，自動列出所有端點、參數類型、回傳格式。它是透過分析你的 Route、Controller、FormRequest、Resource 來推斷結構的。

### 為什麼推薦 Scramble

- **零設定**：裝完就能用，不用在程式碼裡寫一堆 annotation
- **自動同步**：程式碼改了文件就改了，不會有文件過期的問題
- **OpenAPI 標準**：輸出的是標準的 OpenAPI 3.x spec，可以匯入 Postman、Insomnia
- **互動式測試**：在文件頁面直接送請求測試

如果你需要更細緻的文件控制（加範例、加說明），也可以搭配 PHPDoc 補充：

```php
/**
 * 取得團購列表
 *
 * 回傳所有進行中的團購，支援分頁。
 */
public function index()
{
    // ...
}
```

## CORS 設定：跨域請求處理

如果你的 React 或 Vue 前端跑在 `localhost:3000`，API 跑在 `localhost:8000`，瀏覽器會擋住跨域請求。這不是 bug，是瀏覽器的安全機制——**CORS**（Cross-Origin Resource Sharing）。

### 什麼是 CORS

當瀏覽器從 A 網域發請求到 B 網域時，B 必須在 Response Header 裡明確說「我允許 A 來存取」。如果沒有這些 Header，瀏覽器會直接擋掉回應。

```
前端（localhost:3000）→ 請求 → API（localhost:8000）
                                ↓
                         API 回傳 CORS Header:
                         Access-Control-Allow-Origin: http://localhost:3000
                                ↓
                         瀏覽器檢查 Header → OK → 前端拿到資料
```

### Laravel 的 CORS 設定

Laravel 內建 CORS 處理（由全域 middleware 中的 `HandleCors` 自動處理，不發佈設定檔也能運作）。Laravel 12 預設**不會**發佈這個設定檔，需先執行 `php artisan config:publish cors`，才會在 `config/cors.php` 產生它，接著就能編輯：

```php
return [
    &apos;paths&apos; =&gt; [&apos;api/*&apos;],  // 哪些路徑要處理 CORS

    &apos;allowed_methods&apos; =&gt; [&apos;*&apos;],  // GET, POST, PUT, DELETE...

    &apos;allowed_origins&apos; =&gt; [
        &apos;http://localhost:3000&apos;,      // 開發環境前端
        &apos;https://jiuhaomai.tw&apos;,      // 正式環境
    ],

    &apos;allowed_origins_patterns&apos; =&gt; [],

    &apos;allowed_headers&apos; =&gt; [&apos;*&apos;],

    &apos;exposed_headers&apos; =&gt; [],

    &apos;max_age&apos; =&gt; 0,  // Preflight 快取秒數

    &apos;supports_credentials&apos; =&gt; false,
];
```

### 常見踩坑

1. **`allowed_origins` 設成 `[&apos;*&apos;]` 很方便但不安全**——正式環境一定要列出明確的 domain
2. **Preflight request（OPTIONS）被 Nginx 擋掉**——確保你的 Nginx/Apache 設定允許 OPTIONS 方法通過
3. **帶 credentials 時不能用 `*`**——如果 `supports_credentials` 是 `true`，`allowed_origins` 不能用萬用字元，必須列明
4. **忘記加 `api/*` 到 paths**——如果你的 API 路由不在 `api/` 底下，CORS 設定不會生效

&gt; **提醒：** CORS 是**瀏覽器**的機制。手機 App 和 server-to-server 的請求不受 CORS 限制。所以你的 LINE Bot 和後端爬蟲不需要擔心 CORS。

## 實作：揪好買 API 與 LINE Bot 概念整合

把前面學的全部串起來。我們要幫揪好買建一組完整的 API 端點。

### 完整的 API 路由

```php
// routes/api.php
use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\GroupBuyController;

// 認證端點
Route::post(&apos;/register&apos;, [AuthController::class, &apos;register&apos;]);
Route::post(&apos;/login&apos;, [AuthController::class, &apos;login&apos;])
    -&gt;middleware(&apos;throttle:login&apos;);

// 公開端點
Route::get(&apos;/group-buys&apos;, [GroupBuyController::class, &apos;index&apos;]);
Route::get(&apos;/group-buys/{groupBuy}&apos;, [GroupBuyController::class, &apos;show&apos;]);

// 受保護端點
Route::middleware(&apos;auth:sanctum&apos;)-&gt;group(function () {
    // 認證
    Route::post(&apos;/logout&apos;, [AuthController::class, &apos;logout&apos;]);
    Route::get(&apos;/user&apos;, [AuthController::class, &apos;user&apos;]);

    // 團購操作
    Route::post(&apos;/group-buys&apos;, [GroupBuyController::class, &apos;store&apos;]);
    Route::post(&apos;/group-buys/{groupBuy}/join&apos;, [GroupBuyController::class, &apos;join&apos;]);

    // 我的資料
    Route::get(&apos;/my/orders&apos;, [GroupBuyController::class, &apos;myOrders&apos;]);
});
```

### GroupBuyController（API 版）

```php
&lt;?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Resources\GroupBuyResource;
use App\Models\GroupBuy;
use Illuminate\Http\Request;

class GroupBuyController extends Controller
{
    /**
     * 取得進行中的團購列表
     */
    public function index(Request $request)
    {
        $groupBuys = GroupBuy::withCount(&apos;participants&apos;)
            -&gt;where(&apos;status&apos;, &apos;open&apos;)
            -&gt;when($request-&gt;query(&apos;search&apos;), function ($query, $search) {
                $query-&gt;where(&apos;title&apos;, &apos;like&apos;, &quot;%{$search}%&quot;);
            })
            -&gt;latest()
            -&gt;paginate(15);

        return GroupBuyResource::collection($groupBuys);
    }

    /**
     * 取得單一團購詳情
     */
    public function show(GroupBuy $groupBuy)
    {
        $groupBuy-&gt;loadCount(&apos;participants&apos;);
        $groupBuy-&gt;load(&apos;user:id,name&apos;);

        return new GroupBuyResource($groupBuy);
    }

    /**
     * 建立新團購（需要認證）
     */
    public function store(Request $request)
    {
        $this-&gt;authorize(&apos;create&apos;, GroupBuy::class);

        $validated = $request-&gt;validate([
            &apos;title&apos;         =&gt; &apos;required|string|max:255&apos;,
            &apos;description&apos;   =&gt; &apos;required|string&apos;,
            &apos;target_amount&apos; =&gt; &apos;required|integer|min:2&apos;,
            &apos;deadline&apos;      =&gt; &apos;required|date|after:now&apos;,
            &apos;price_per_unit&apos;=&gt; &apos;required|numeric|min:0&apos;,
        ]);

        $groupBuy = $request-&gt;user()-&gt;groupBuys()-&gt;create($validated);

        return (new GroupBuyResource($groupBuy))
            -&gt;response()
            -&gt;setStatusCode(201);
    }

    /**
     * 加入團購
     */
    public function join(Request $request, GroupBuy $groupBuy)
    {
        // 檢查團購是否還開放
        if ($groupBuy-&gt;status !== &apos;open&apos;) {
            return response()-&gt;json([
                &apos;message&apos; =&gt; &apos;此團購已截止或已取消。&apos;,
            ], 422);
        }

        // 檢查是否已經加入
        if ($groupBuy-&gt;participants()-&gt;where(&apos;user_id&apos;, $request-&gt;user()-&gt;id)-&gt;exists()) {
            return response()-&gt;json([
                &apos;message&apos; =&gt; &apos;你已經加入這個團購了。&apos;,
            ], 422);
        }

        $validated = $request-&gt;validate([
            &apos;quantity&apos; =&gt; &apos;required|integer|min:1&apos;,
        ]);

        $groupBuy-&gt;participants()-&gt;attach($request-&gt;user()-&gt;id, [
            &apos;quantity&apos; =&gt; $validated[&apos;quantity&apos;],
        ]);

        return response()-&gt;json([
            &apos;message&apos; =&gt; &apos;成功加入團購！&apos;,
        ]);
    }

    /**
     * 我的訂單
     */
    public function myOrders(Request $request)
    {
        $orders = $request-&gt;user()
            -&gt;joinedGroupBuys()
            -&gt;withPivot(&apos;quantity&apos;)
            -&gt;withCount(&apos;participants&apos;)
            -&gt;latest()
            -&gt;paginate(15);

        return GroupBuyResource::collection($orders);
    }
}
```

### ParticipantResource（跟團者資訊）

如果 API 需要回傳參與者列表，也用 Resource 包裝：

```php
&lt;?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class ParticipantResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            &apos;user&apos; =&gt; [
                &apos;id&apos;   =&gt; $this-&gt;id,
                &apos;name&apos; =&gt; $this-&gt;name,
            ],
            &apos;quantity&apos;  =&gt; $this-&gt;pivot-&gt;quantity,
            &apos;joined_at&apos; =&gt; $this-&gt;pivot-&gt;created_at?-&gt;toIso8601String(),
        ];
    }
}
```

### 用 curl 測試

API 不需要瀏覽器就能測試。用 `curl` 或 `httpie` 在終端機直接打：

```bash
# 註冊
curl -X POST http://localhost:8000/api/register \
  -H &quot;Content-Type: application/json&quot; \
  -H &quot;Accept: application/json&quot; \
  -d &apos;{
    &quot;name&quot;: &quot;小陳&quot;,
    &quot;email&quot;: &quot;chen@example.com&quot;,
    &quot;password&quot;: &quot;password123&quot;,
    &quot;password_confirmation&quot;: &quot;password123&quot;
  }&apos;

# 回傳：{&quot;user&quot;:{&quot;id&quot;:1,&quot;name&quot;:&quot;小陳&quot;,&quot;email&quot;:&quot;chen@example.com&quot;},&quot;token&quot;:&quot;1|abc123...&quot;}
```

```bash
# 登入
curl -X POST http://localhost:8000/api/login \
  -H &quot;Content-Type: application/json&quot; \
  -H &quot;Accept: application/json&quot; \
  -d &apos;{&quot;email&quot;: &quot;chen@example.com&quot;, &quot;password&quot;: &quot;password123&quot;}&apos;
```

```bash
# 查看團購列表（不需要 token）
curl http://localhost:8000/api/group-buys \
  -H &quot;Accept: application/json&quot;
```

```bash
# 加入團購（需要 token）
curl -X POST http://localhost:8000/api/group-buys/42/join \
  -H &quot;Content-Type: application/json&quot; \
  -H &quot;Accept: application/json&quot; \
  -H &quot;Authorization: Bearer 1|abc123...&quot; \
  -d &apos;{&quot;quantity&quot;: 2}&apos;
```

```bash
# 如果你裝了 httpie，語法更簡潔
http POST localhost:8000/api/login email=chen@example.com password=password123
http localhost:8000/api/group-buys Authorization:&quot;Bearer 1|abc123...&quot;
```

&gt; **重要：** 請求 API 時一定要帶 `Accept: application/json` Header。如果不帶，Laravel 在驗證失敗時會嘗試 redirect 到一個 HTML 頁面——在 API 裡這會變成很奇怪的 302 回應。

### LINE Bot Webhook 概念整合

LINE Bot 的運作邏輯是：使用者在 LINE 群組裡傳訊息 → LINE 平台把訊息轉送到你的 webhook URL → 你的伺服器處理後回覆。

```
使用者在 LINE 傳「查團購」
        ↓
LINE 平台 → POST /api/line/webhook（你的伺服器）
        ↓
伺服器解析訊息，查詢 GroupBuy 資料
        ↓
伺服器透過 LINE Messaging API 回覆結果
```

webhook 端點的概念實作：

```php
// routes/api.php
Route::post(&apos;/line/webhook&apos;, [LineWebhookController::class, &apos;handle&apos;]);
```

```php
&lt;?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\GroupBuy;
use Illuminate\Http\Request;

class LineWebhookController extends Controller
{
    public function handle(Request $request)
    {
        // LINE 送來的 webhook 事件
        $events = $request-&gt;input(&apos;events&apos;, []);

        foreach ($events as $event) {
            if ($event[&apos;type&apos;] !== &apos;message&apos;) {
                continue;
            }

            $text = $event[&apos;message&apos;][&apos;text&apos;] ?? &apos;&apos;;
            $replyToken = $event[&apos;replyToken&apos;];

            if (str_contains($text, &apos;查團購&apos;)) {
                $this-&gt;replyGroupBuyList($replyToken);
            } elseif (str_contains($text, &apos;開團中&apos;)) {
                $this-&gt;replyOpenGroupBuys($replyToken);
            }
        }

        return response()-&gt;json([&apos;status&apos; =&gt; &apos;ok&apos;]);
    }

    private function replyGroupBuyList(string $replyToken): void
    {
        $groupBuys = GroupBuy::where(&apos;status&apos;, &apos;open&apos;)
            -&gt;latest()
            -&gt;take(5)
            -&gt;get();

        $message = &quot;目前開團中的團購：\n\n&quot;;

        foreach ($groupBuys as $gb) {
            $message .= &quot;🛒 {$gb-&gt;title}\n&quot;;
            $message .= &quot;   截止：{$gb-&gt;deadline-&gt;format(&apos;m/d H:i&apos;)}\n&quot;;
            $message .= &quot;   目標：{$gb-&gt;current_amount}/{$gb-&gt;target_amount} 人\n\n&quot;;
        }

        // 實際整合時透過 LINE Messaging API SDK 回覆
        // v7 SDK：$messagingApi-&gt;replyMessage(new ReplyMessageRequest([
        //     &apos;replyToken&apos; =&gt; $replyToken,
        //     &apos;messages&apos; =&gt; [new TextMessage([&apos;type&apos; =&gt; &apos;text&apos;, &apos;text&apos; =&gt; $message])],
        // ])); // 舊版 LINEBot::replyText 已淘汰
    }

    private function replyOpenGroupBuys(string $replyToken): void
    {
        $count = GroupBuy::where(&apos;status&apos;, &apos;open&apos;)-&gt;count();

        $message = &quot;目前有 {$count} 個團購進行中！\n輸入「查團購」看詳細列表。&quot;;

        // v7 SDK：$messagingApi-&gt;replyMessage(new ReplyMessageRequest([...]));（舊版 LINEBot::replyText 已淘汰）
    }
}
```

這裡只展示概念——實際的 LINE Bot 整合需要安裝 `linecorp/line-bot-sdk`、設定 Channel Access Token 和 Channel Secret、做 Signature 驗證。但核心架構就是這樣：一個 webhook 端點，接收訊息、查詢資料庫、回覆結果。**你的 API 和 LINE Bot 共用同一個資料庫和 Model**，不需要重寫任何商業邏輯。

### 錯誤回應的統一格式

好的 API 在出錯時也要回傳結構化的 JSON，而不是 HTML 錯誤頁面。在 `bootstrap/app.php` 統一處理：

```php
use Illuminate\Http\Request;

-&gt;withExceptions(function (Exceptions $exceptions) {
    $exceptions-&gt;shouldRenderJsonWhen(function (Request $request) {
        return $request-&gt;is(&apos;api/*&apos;) || $request-&gt;expectsJson();
    });
})
```

這樣所有 `/api/*` 路由的例外都會以 JSON 格式回傳：

```json
{
    &quot;message&quot;: &quot;此團購已截止或已取消。&quot;,
    &quot;errors&quot;: {}
}
```

驗證錯誤則會自動回傳 422 和欄位明細：

```json
{
    &quot;message&quot;: &quot;The title field is required.&quot;,
    &quot;errors&quot;: {
        &quot;title&quot;: [&quot;The title field is required.&quot;],
        &quot;deadline&quot;: [&quot;The deadline must be a date after now.&quot;]
    }
}
```

## 小結：一套後端，多個前端

這一章我們把揪好買從「只服務瀏覽器」升級成「服務所有人」：

**API 基礎：**
- `php artisan install:api` 啟用 API 路由和 Sanctum
- `routes/api.php` 是 stateless 的——沒有 Session、沒有 CSRF，靠 Token 認證
- API Resource 精準控制 JSON 輸出，搭配 Pagination 自動生成分頁資訊

**認證與安全：**
- Sanctum Token 認證——`createToken()` 建立、`Bearer` Header 攜帶、`delete()` 撤銷
- Token Abilities 做細粒度權限控制
- Rate Limiting 防止 API 被濫用——已登入使用者和匿名使用者各自獨立限制

**實務面：**
- CORS 設定讓 SPA 前端能跨域存取
- Scramble 自動生成 API 文件，零設定
- LINE Bot webhook 概念——同一個 Laravel 專案服務所有消費者
- API 版本管理 YAGNI——等需要時再加

現在揪好買有了完整的 API 端點，手機 App 可以呼叫、LINE Bot 可以整合、第三方系統可以串接。但平台越來越大，開團主需要看統計報表，管理員需要後台管理介面，團購列表開始變慢——下一章，我們要用 **[Filament 3 快速建立管理後台](/blog/laravel-guide-admin-filament-advanced-queries/)**，並深入 Eloquent 進階查詢技巧，解決真實世界的效能問題。</content:encoded><media:content url="https://bobochen.dev/_astro/cover.Dqum1izr.webp" medium="image"/><category>PHP</category><category>Laravel</category><category>API</category><category>Sanctum</category><category>REST</category><category>LINE Bot</category><enclosure url="https://bobochen.dev/_astro/cover.Dqum1izr.webp" length="0" type="image/png"/></item><item><title>Queue 與 Event：讓耗時任務不阻塞使用者</title><link>https://bobochen.dev/blog/laravel-guide-queues-events-notifications/</link><guid isPermaLink="true">https://bobochen.dev/blog/laravel-guide-queues-events-notifications/</guid><description>成團後要寄 50 封信、推播、更新統計，全塞在同一個 request 會讓使用者等十秒。用 Laravel 12 的 Queue 把耗時任務丟到背景、用 Event/Listener 解耦成團後的多個動作、用 Notification 一個類別搞定 Email 與站內訊息——以「揪好買」團購平台實作完整非同步通知系統，含 queue:work 失敗重試與 Horizon 監控。</description><pubDate>Tue, 06 May 2025 00:00:00 GMT</pubDate><content:encoded>[上一章](/blog/laravel-guide-orders-stripe-cashier/)成團收款的瞬間，你需要做一堆事：寄成團確認信給所有跟團者、寄通知給開團主、更新團購狀態、生成訂單明細 PDF、紀錄 analytics 事件。如果這些全部塞在同一個 HTTP request 裡同步執行，使用者按下確認後要等五到十秒才看到回應——這種體驗在 2026 年是不能接受的。

Queue（佇列）就是解決這個問題的：把不需要即時完成的工作丟到背景去跑，使用者的 request 馬上就能回應。

但 Queue 只解決了「何時做」的問題。「誰該做什麼」的問題，需要 Event/Listener 模式來處理。當「團購成團」這個事件發生時，可能有五六個不同的動作要觸發——寄信、推播、更新統計、通知倉庫備貨。如果把這些全寫在 Controller 或 Service 裡，程式碼會變成一團義大利麵。

Event/Listener 讓你把「事件」和「反應」解耦：Controller 只要 dispatch 一個 `GroupBuyConfirmed` event，各個 Listener 自己知道該做什麼。新增需求？加一個 Listener 就好，不用動到原本的程式碼。

Laravel 還有一個被低估的利器：Notification。它提供統一的介面來發送通知，不管你要透過 Email、SMS、Slack、站內訊息還是 push notification——同一個 Notification class 搞定所有管道。在「揪好買」裡，我們會把成團通知、出貨通知、截止提醒全部用 Notification 來實作，搭配 Queue 讓它們在背景發送。

## 為什麼需要 Queue：使用者不該等你寄 Email

想像這個場景：一個團購有 50 個跟團者，成團後你要寄確認信給每個人。每封信透過 SMTP 發送大約需要 0.5 秒，50 封就是 25 秒。如果是同步執行，使用者按下「確認成團」之後，要盯著 loading 轉圈 25 秒才能看到結果。

```text
同步處理（Synchronous）：
使用者按下確認 → 寄信給 A（0.5s）→ 寄信給 B（0.5s）→ ... → 寄信給第 50 人（0.5s）→ 回應使用者
                                        總共 25 秒 ⏳

非同步處理（Asynchronous with Queue）：
使用者按下確認 → 把 50 封信丟進 Queue → 回應使用者 ✅（0.1s）
                    ↓
              背景 Worker 慢慢寄，使用者不用等
```

這就是 Queue 的核心價值：**把不需要使用者等待的工作延遲到背景執行**。常見的適用場景：

- **寄送 Email / SMS / 推播通知**——使用者不需要等你跟 SMTP Server 握手
- **生成 PDF 或 Excel 報表**——耗時的運算不該卡住 request
- **呼叫第三方 API**——外部服務的回應時間你控制不了
- **圖片處理**——裁切、壓縮、上傳到 CDN
- **資料同步**——把訂單資料推到 ERP 或會計系統

不過在你決定全部丟 Queue 之前，先把帳算清楚。Queue 不是免費的：你的系統會從「一個 PHP process 收 request、回 response」變成「還要養一個常駐 worker、設 Supervisor 讓它掛掉自動拉起、加監控、部署時記得 `php artisan queue:restart`」的分散式架構。對流量很小的專案，這個維運成本常常大於「使用者少等三秒」省下來的時間——這時候同步處理加一個 loading 轉圈，反而更簡單、更可靠、半夜也不會出事。而且非同步多了一個很容易忽略的 failure mode：worker 沒在跑的時候，任務不會報錯，它只是安靜地躺在佇列裡不動，通知就默默沒送出去，沒人會發現。同步至少會當場噴錯給你看。所以這章後面我會花篇幅講 Supervisor 跟失敗監控，不是順帶提一下——那是用 Queue 的入場費，不是加分題。

如果你用過 Node.js，你可能會想：「JavaScript 本來就是非同步的啊，用 `Promise` 不就好了？」問題在於，PHP 的每個 request 是獨立的 process。request 結束了，process 就結束了，不會有「背景繼續跑」的機會。

Queue 的做法是把任務序列化後存到某個地方（資料庫、Redis、SQS），然後由獨立的 worker process 去取出來執行。概念上類似 Python 的 Celery 或 Node.js 的 Bull/BullMQ。

## Queue 概念與 Driver 選擇

Laravel 的 Queue 系統支援多種 driver（驅動），讓你根據環境和規模選擇適合的後端：

| Driver     | 適用場景          | 優點                     | 缺點                   |
| ---------- | ----------------- | ------------------------ | ---------------------- |
| `sync`     | 本地開發/除錯     | 同步執行，方便 debug     | 不是真的 queue，會阻塞 |
| `database` | 小型專案/開發環境 | 不需額外服務             | 效能較差，polling 機制 |
| `redis`    | **正式環境首選**  | 快、支援優先級、延遲任務 | 需要 Redis 服務        |
| `sqs`      | AWS 大規模部署    | 完全託管、自動擴展       | 綁定 AWS               |

在「揪好買」的開發環境，我們用 `database` driver 就夠了——不需要額外安裝 Redis，資料庫裡開一張 table 就能跑。正式環境再切到 `redis`，只要改一行 `.env` 設定。

### 設定 Database Queue Driver

```bash
# .env
QUEUE_CONNECTION=database
```

建立 Queue 需要的資料表：

```bash
php artisan queue:table
php artisan migrate
```

這會建立一張 `jobs` table，Queue 把待執行的任務序列化後存在這裡。若需要批次處理（Job Batching），另外執行 `php artisan queue:batches-table` 建立 `job_batches` table。另外，我們還需要一張 `failed_jobs` table 來記錄失敗的任務：

```bash
php artisan queue:failed-table
php artisan migrate
```

&gt; **提示：** Laravel 12 的新專案預設就會幫你建好這些 migration。如果你是從舊版升級，才需要手動執行上面的指令。

## Job 類別：把任務包裝起來

Queue 裡的每個任務就是一個 Job class。用 Artisan 指令快速生成：

```bash
php artisan make:job ProcessGroupBuyConfirmation
```

這會在 `app/Jobs/` 目錄下建立一個檔案：

```php
&lt;?php

namespace App\Jobs;

use App\Models\GroupBuy;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class ProcessGroupBuyConfirmation implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public readonly GroupBuy $groupBuy,
    ) {}

    public function handle(): void
    {
        // 更新團購狀態
        $this-&gt;groupBuy-&gt;update([&apos;status&apos; =&gt; &apos;confirmed&apos;]);

        // 生成訂單明細 PDF
        $pdf = PDF::loadView(&apos;pdf.group-buy-summary&apos;, [
            &apos;groupBuy&apos; =&gt; $this-&gt;groupBuy,
            &apos;participants&apos; =&gt; $this-&gt;groupBuy-&gt;participants()-&gt;with(&apos;orders&apos;)-&gt;get(),
        ]);

        Storage::put(
            &quot;summaries/{$this-&gt;groupBuy-&gt;id}.pdf&quot;,
            $pdf-&gt;output()
        );
    }
}
```

幾個關鍵點：

- **`implements ShouldQueue`**——這個 interface 告訴 Laravel：「這個 Job 要丟到 Queue 裡背景執行」。如果拿掉它，Job 會同步執行（跟沒有 Queue 一樣）。
- **`SerializesModels`**——這個 trait 會自動序列化 Eloquent Model 的 ID，然後在 worker 端重新從資料庫取出完整的 Model。避免把整個 Model 物件塞進 Queue（那會很大，而且資料可能過時）。
- **`handle()` 方法**——worker 從 Queue 取出任務後，就是執行這個方法。你的業務邏輯寫在這裡。

### 分派 Job

在 Controller 或 Service 裡，把 Job 丟進 Queue：

```php
// 最常用的方式
ProcessGroupBuyConfirmation::dispatch($groupBuy);

// 延遲執行：5 分鐘後才執行
ProcessGroupBuyConfirmation::dispatch($groupBuy)
    -&gt;delay(now()-&gt;addMinutes(5));

// 指定 Queue 名稱（用來區分優先級）
ProcessGroupBuyConfirmation::dispatch($groupBuy)
    -&gt;onQueue(&apos;high&apos;);

// 用 dispatch helper function
dispatch(new ProcessGroupBuyConfirmation($groupBuy));
```

### 重試與超時設定

Job 失敗時的行為可以直接在 class 上設定：

```php
class ProcessGroupBuyConfirmation implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;           // 最多重試 3 次
    public int $timeout = 60;        // 超過 60 秒就算超時
    public int $maxExceptions = 2;   // 最多容忍 2 個例外

    // 退避策略：第一次失敗等 10 秒、第二次等 30 秒、第三次等 60 秒
    public function backoff(): array
    {
        return [10, 30, 60];
    }

    // ...
}
```

### Job Middleware

如果你需要防止同一個 Job 重複執行（例如同一個團購的確認信不該寄兩次），可以用 Job Middleware：

```php
use Illuminate\Queue\Middleware\WithoutOverlapping;

public function middleware(): array
{
    return [
        new WithoutOverlapping($this-&gt;groupBuy-&gt;id),
    ];
}
```

`WithoutOverlapping` 會用 lock 機制確保同一個 key 的 Job 不會同時執行。在「揪好買」裡，用團購 ID 當 key 是最直覺的選擇。

## Event 與 Listener：解耦業務邏輯

Queue 解決了「非同步執行」的問題，但還有另一個問題：**當一個動作需要觸發多個後續動作時，程式碼要怎麼組織？**

不好的寫法是把所有邏輯塞在 Controller：

```php
// ❌ 不好：Controller 變成 God Object
class GroupBuyController extends Controller
{
    public function confirm(GroupBuy $groupBuy)
    {
        $groupBuy-&gt;update([&apos;status&apos; =&gt; &apos;confirmed&apos;]);

        // 寄確認信給跟團者
        foreach ($groupBuy-&gt;participants as $user) {
            Mail::to($user)-&gt;send(new GroupBuyConfirmedMail($groupBuy));
        }

        // 寄摘要給開團主
        Mail::to($groupBuy-&gt;organizer)-&gt;send(new OrganizerSummaryMail($groupBuy));

        // 更新統計
        $groupBuy-&gt;increment(&apos;confirmed_count&apos;);

        // 通知倉庫備貨
        Http::post(&apos;https://warehouse-api.example.com/prepare&apos;, [...]);

        // 紀錄 analytics
        Analytics::track(&apos;group_buy_confirmed&apos;, [...]);

        return redirect()-&gt;route(&apos;group-buys.show&apos;, $groupBuy);
    }
}
```

問題在哪？每次新增需求（例如「成團後也要發 LINE 通知」），你都要改這個 Controller。這違反了開放封閉原則（Open/Closed Principle）——**對擴展開放，對修改封閉**。

Event/Listener 模式的做法是：Controller 只做一件事——dispatch 一個 Event。其他的後續動作由各自的 Listener 處理：

```php
// ✅ 好：Controller 只 dispatch event
class GroupBuyController extends Controller
{
    public function confirm(GroupBuy $groupBuy)
    {
        $groupBuy-&gt;update([&apos;status&apos; =&gt; &apos;confirmed&apos;]);

        event(new GroupBuyConfirmed($groupBuy));

        return redirect()-&gt;route(&apos;group-buys.show&apos;, $groupBuy);
    }
}
```

### 建立 Event

```bash
php artisan make:event GroupBuyConfirmed
```

```php
&lt;?php

namespace App\Events;

use App\Models\GroupBuy;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class GroupBuyConfirmed
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public function __construct(
        public readonly GroupBuy $groupBuy,
    ) {}
}
```

Event class 本身很單純——它就是一個資料容器，攜帶事件發生時的相關資訊。不需要任何業務邏輯。

### 建立 Listener

```bash
php artisan make:listener SendConfirmationToParticipants --event=GroupBuyConfirmed
php artisan make:listener SendOrganizerSummary --event=GroupBuyConfirmed
php artisan make:listener UpdateGroupBuyStats --event=GroupBuyConfirmed
```

```php
&lt;?php

namespace App\Listeners;

use App\Events\GroupBuyConfirmed;
use App\Notifications\GroupBuyConfirmedNotification;
use Illuminate\Contracts\Queue\ShouldQueue;

class SendConfirmationToParticipants implements ShouldQueue
{
    public function handle(GroupBuyConfirmed $event): void
    {
        $groupBuy = $event-&gt;groupBuy;

        foreach ($groupBuy-&gt;participants as $user) {
            $user-&gt;notify(new GroupBuyConfirmedNotification($groupBuy));
        }
    }
}
```

注意 `implements ShouldQueue`——加上這個 interface，Listener 就會自動在背景執行。不加的話就是同步執行。你可以根據每個 Listener 的特性來決定：寄信要 Queue、更新資料庫統計可以同步。

&gt; **背景化之前先想清楚 debug 怎麼辦**
&gt;
&gt; 「寄信要 Queue」這句話我得補一個但書。同步寄信失敗時，stack trace 直接打在那個 request 上，使用者當場看到錯誤、你的 log 也立刻有紀錄。一旦丟進 Queue，失敗就搬到另一個 process、另一個時間點發生了——使用者那邊畫面顯示「確認成功」，信其實沒寄出去，而且沒人會知道。這個「使用者以為成功、實際上失敗」的落差會無聲累積，等客訴進來才發現往往已經漏掉一堆。
&gt;
&gt; 所以非同步化跟「主動建可觀測性」是綁在一起的，不能只做前者：至少要對 `failed_jobs` 設告警，正式環境上 Horizon 看佇列健康度，例外接到 Sentry。開發期則反過來——把 `QUEUE_CONNECTION` 設成 `sync`，或用 `php artisan queue:work --once` 一次跑一個，讓失敗回到 request 生命週期裡，你才 debug 得動。背景執行很爽，但你是拿「看得見的失敗」去換的，這筆交易要心裡有數。

```php
&lt;?php

namespace App\Listeners;

use App\Events\GroupBuyConfirmed;

class UpdateGroupBuyStats
{
    // 沒有 implements ShouldQueue → 同步執行
    public function handle(GroupBuyConfirmed $event): void
    {
        $groupBuy = $event-&gt;groupBuy;
        $groupBuy-&gt;update([
            &apos;participant_count&apos; =&gt; $groupBuy-&gt;participants()-&gt;count(),
            &apos;total_amount&apos; =&gt; $groupBuy-&gt;orders()-&gt;sum(&apos;amount&apos;),
            &apos;confirmed_at&apos; =&gt; now(),
        ]);
    }
}
```

### 註冊 Event 與 Listener

Laravel 12 支援**自動發現**——只要 Listener 的 `handle()` 方法有正確的 type hint，Laravel 會自動把它跟 Event 配對。不需要在任何地方手動註冊。

如果你需要明確控制（或是自動發現沒生效），可以在 `AppServiceProvider` 的 `boot()` 方法裡手動註冊：

```php
use App\Events\GroupBuyConfirmed;
use App\Listeners\SendConfirmationToParticipants;
use App\Listeners\SendOrganizerSummary;
use App\Listeners\UpdateGroupBuyStats;
use Illuminate\Support\Facades\Event;

public function boot(): void
{
    Event::listen(GroupBuyConfirmed::class, [
        SendConfirmationToParticipants::class,
        SendOrganizerSummary::class,
        UpdateGroupBuyStats::class,
    ]);
}
```

要確認所有 Event/Listener 的對應關係，可以用：

```bash
php artisan event:list
```

### 跨框架概念對照

如果你從其他語言過來，Event/Listener 的概念不會太陌生：

| 框架/語言         | 對應機制                  | 差異點                                                                 |
| ----------------- | ------------------------- | ---------------------------------------------------------------------- |
| **Node.js**       | `EventEmitter`            | Node 的 event 是 in-process、同步觸發；Laravel 可以選擇 Queue 背景執行 |
| **Python/Django** | Signals（`post_save` 等） | Django 的 signal 是同步的，沒有內建的非同步機制                        |
| **React**         | Custom Events / Context   | 前端的事件系統，概念類似但作用域不同                                   |
| **Spring**        | `@EventListener`          | 最接近 Laravel 的做法，也支援 async                                    |

Laravel 的 Event 系統最大的優勢是它跟 Queue 的深度整合——一個 `implements ShouldQueue` 就能把 Listener 從同步變成非同步，不需要額外的配置。

## Notification：統一的通知發送介面

你可能會想：「寄信直接用 `Mail::send()` 不就好了？幹嘛還要 Notification？」

問題是，真實世界的通知不只有 Email。成團通知你可能要同時寄 Email + 存到站內訊息 + 推 LINE 通知。如果分三個地方寫，資料格式不一致，日後加一個管道就要改三個地方。

Notification 的設計理念是：**一個通知類別，多種發送管道**。

```bash
php artisan make:notification GroupBuyConfirmedNotification
```

```php
&lt;?php

namespace App\Notifications;

use App\Models\GroupBuy;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

class GroupBuyConfirmedNotification extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public readonly GroupBuy $groupBuy,
    ) {}

    /**
     * 決定這個通知要透過哪些管道發送
     */
    public function via(object $notifiable): array
    {
        return [&apos;mail&apos;, &apos;database&apos;];
    }

    /**
     * Email 版本
     */
    public function toMail(object $notifiable): MailMessage
    {
        return (new MailMessage)
            -&gt;subject(&quot;🎉 團購「{$this-&gt;groupBuy-&gt;title}」已成團！&quot;)
            -&gt;greeting(&quot;Hi {$notifiable-&gt;name}，&quot;)
            -&gt;line(&quot;好消息！你參加的團購「{$this-&gt;groupBuy-&gt;title}」已經達到最低人數，正式成團了。&quot;)
            -&gt;line(&quot;成團人數：{$this-&gt;groupBuy-&gt;participant_count} 人&quot;)
            -&gt;line(&quot;總金額：NT$ &quot; . number_format($this-&gt;groupBuy-&gt;total_amount))
            -&gt;action(&apos;查看團購詳情&apos;, route(&apos;group-buys.show&apos;, $this-&gt;groupBuy))
            -&gt;line(&apos;感謝你的參與，我們會儘快安排出貨！&apos;);
    }

    /**
     * 站內訊息版本（存到 notifications table）
     */
    public function toDatabase(object $notifiable): array
    {
        return [
            &apos;group_buy_id&apos; =&gt; $this-&gt;groupBuy-&gt;id,
            &apos;group_buy_title&apos; =&gt; $this-&gt;groupBuy-&gt;title,
            &apos;message&apos; =&gt; &quot;團購「{$this-&gt;groupBuy-&gt;title}」已成團&quot;,
            &apos;type&apos; =&gt; &apos;group_buy_confirmed&apos;,
        ];
    }
}
```

`via()` 方法決定要用哪些管道發送。你甚至可以根據使用者的偏好動態決定：

```php
public function via(object $notifiable): array
{
    $channels = [&apos;database&apos;]; // 站內訊息一定有

    if ($notifiable-&gt;email_notifications_enabled) {
        $channels[] = &apos;mail&apos;;
    }

    if ($notifiable-&gt;line_token) {
        $channels[] = &apos;line&apos;; // 自訂管道
    }

    return $channels;
}
```

### 發送通知

有兩種方式：

```php
// 方式一：透過 Notifiable trait（User Model 預設就有）
$user-&gt;notify(new GroupBuyConfirmedNotification($groupBuy));

// 方式二：透過 Notification Facade（可以一次寄給多人）
use Illuminate\Support\Facades\Notification;

Notification::send(
    $groupBuy-&gt;participants,  // Collection of users
    new GroupBuyConfirmedNotification($groupBuy)
);
```

方式二在「揪好買」更常用——成團的時候要一次通知所有跟團者。

## Mail 通知：寄出成團確認信

上面的 `toMail()` 用的是 `MailMessage` builder——它會自動套用 Laravel 內建的 Email 模板。但如果你想要更漂亮、更品牌化的 Email，可以用 Markdown 模板：

```php
public function toMail(object $notifiable): MailMessage
{
    return (new MailMessage)
        -&gt;subject(&quot;🎉 團購「{$this-&gt;groupBuy-&gt;title}」已成團！&quot;)
        -&gt;markdown(&apos;emails.group-buy-confirmed&apos;, [
            &apos;user&apos; =&gt; $notifiable,
            &apos;groupBuy&apos; =&gt; $this-&gt;groupBuy,
            &apos;orders&apos; =&gt; $this-&gt;groupBuy-&gt;orders()
                -&gt;where(&apos;user_id&apos;, $notifiable-&gt;id)
                -&gt;get(),
        ]);
}
```

對應的 Markdown 模板 `resources/views/emails/group-buy-confirmed.blade.php`：

```blade
&lt;x-mail::message&gt;
# 🎉 {{ $groupBuy-&gt;title }} 已成團！

Hi {{ $user-&gt;name }}，

你參加的團購已經達到最低人數，正式成團了。以下是你的訂購明細：

&lt;x-mail::table&gt;
| 品項 | 數量 | 小計 |
|:-----|:----:|-----:|
@foreach ($orders as $order)
| {{ $order-&gt;product_name }} | {{ $order-&gt;quantity }} | NT$ {{ number_format($order-&gt;amount) }} |
@endforeach
| **合計** | | **NT$ {{ number_format($orders-&gt;sum(&apos;amount&apos;)) }}** |
&lt;/x-mail::table&gt;

&lt;x-mail::button :url=&quot;route(&apos;group-buys.show&apos;, $groupBuy)&quot;&gt;
查看團購詳情
&lt;/x-mail::button&gt;

感謝你使用揪好買！

{{ config(&apos;app.name&apos;) }}
&lt;/x-mail::message&gt;
```

Laravel 的 Mail Markdown 元件（`x-mail::message`、`x-mail::table`、`x-mail::button`）會自動轉換成漂亮的 HTML Email。

### 開發環境測試：不要真的寄信

在開發階段，你不會想真的寄 Email 出去。在 `.env` 裡設定：

```bash
MAIL_MAILER=log
```

這樣所有「寄出的」Email 都會寫到 `storage/logs/laravel.log`，你可以在 log 裡看到完整的 Email 內容，確認格式正確。

另一個好用的選擇是 [Mailpit](https://mailpit.axllent.org/)——一個本地的 Email 測試伺服器，會攔截所有寄出的信，讓你在瀏覽器裡預覽：

```bash
MAIL_MAILER=smtp
MAIL_HOST=localhost
MAIL_PORT=1025
```

## Database 通知：站內訊息

不是每個人都會看 Email。站內訊息（in-app notification）是另一個重要的通知管道。使用者登入後在右上角看到小鈴鐺和紅色數字——這就是 Database 通知的用途。

### 設定 Notifications Table

```bash
php artisan notifications:table
php artisan migrate
```

這會建立一張 `notifications` table，結構大概是這樣：

| 欄位            | 型別      | 說明                          |
| --------------- | --------- | ----------------------------- |
| id              | uuid      | 通知 ID                       |
| type            | string    | Notification class 名稱       |
| notifiable_type | string    | 被通知的 Model（通常是 User） |
| notifiable_id   | bigint    | 被通知的 Model ID             |
| data            | json      | `toDatabase()` 回傳的資料     |
| read_at         | timestamp | 已讀時間（null = 未讀）       |
| created_at      | timestamp | 建立時間                      |

### 讀取與標記已讀

在 User Model 上（透過 `Notifiable` trait），你可以這樣操作：

```php
// 取得所有通知
$user-&gt;notifications;

// 取得未讀通知
$user-&gt;unreadNotifications;

// 取得未讀數量
$user-&gt;unreadNotifications-&gt;count();

// 標記單一通知為已讀
$notification-&gt;markAsRead();

// 標記所有通知為已讀
$user-&gt;unreadNotifications-&gt;markAsRead();
```

### 在 Blade 裡顯示通知鈴鐺

一個常見的 UI 實作——導覽列上的通知鈴鐺：

```blade
{{-- resources/views/components/notification-bell.blade.php --}}
@auth
&lt;div class=&quot;relative&quot; x-data=&quot;{ open: false }&quot;&gt;
    &lt;button @click=&quot;open = !open&quot; class=&quot;relative p-2&quot;&gt;
        {{-- 鈴鐺圖示 --}}
        &lt;svg class=&quot;w-6 h-6&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; viewBox=&quot;0 0 24 24&quot;&gt;
            &lt;path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; stroke-width=&quot;2&quot;
                  d=&quot;M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11
                     a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341
                     C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436
                     L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9&quot; /&gt;
        &lt;/svg&gt;

        {{-- 未讀紅點 --}}
        @if (auth()-&gt;user()-&gt;unreadNotifications-&gt;count() &gt; 0)
            &lt;span class=&quot;absolute -top-1 -right-1 bg-red-500 text-white text-xs
                         rounded-full w-5 h-5 flex items-center justify-center&quot;&gt;
                {{ auth()-&gt;user()-&gt;unreadNotifications-&gt;count() }}
            &lt;/span&gt;
        @endif
    &lt;/button&gt;

    {{-- 下拉通知列表 --}}
    &lt;div x-show=&quot;open&quot; @click.away=&quot;open = false&quot;
         class=&quot;absolute right-0 mt-2 w-80 bg-white rounded-lg shadow-lg z-50&quot;&gt;
        &lt;div class=&quot;p-4&quot;&gt;
            &lt;h3 class=&quot;font-semibold mb-2&quot;&gt;通知&lt;/h3&gt;

            @forelse (auth()-&gt;user()-&gt;notifications()-&gt;latest()-&gt;take(10)-&gt;get() as $notification)
                &lt;a href=&quot;{{ route(&apos;notifications.read&apos;, $notification-&gt;id) }}&quot;
                   class=&quot;block p-3 rounded hover:bg-gray-50
                          {{ $notification-&gt;read_at ? &apos;opacity-60&apos; : &apos;bg-blue-50&apos; }}&quot;&gt;
                    &lt;p class=&quot;text-sm&quot;&gt;{{ $notification-&gt;data[&apos;message&apos;] }}&lt;/p&gt;
                    &lt;p class=&quot;text-xs text-gray-400 mt-1&quot;&gt;
                        {{ $notification-&gt;created_at-&gt;diffForHumans() }}
                    &lt;/p&gt;
                &lt;/a&gt;
            @empty
                &lt;p class=&quot;text-sm text-gray-400&quot;&gt;暫無通知&lt;/p&gt;
            @endforelse
        &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;
@endauth
```

對應的 Controller 處理「點擊通知 → 標記已讀 → 跳轉」：

```php
class NotificationController extends Controller
{
    public function read(string $id)
    {
        $notification = auth()-&gt;user()
            -&gt;notifications()
            -&gt;findOrFail($id);

        $notification-&gt;markAsRead();

        // 根據通知類型跳轉到對應頁面
        return match ($notification-&gt;data[&apos;type&apos;] ?? null) {
            &apos;group_buy_confirmed&apos; =&gt; redirect()-&gt;route(
                &apos;group-buys.show&apos;,
                $notification-&gt;data[&apos;group_buy_id&apos;]
            ),
            default =&gt; redirect()-&gt;route(&apos;dashboard&apos;),
        };
    }
}
```

## 失敗 Job 處理與重試策略

Queue 不是萬能的——Job 會失敗。SMTP Server 掛了、第三方 API 回應逾時、資料庫連線斷了，這些在分散式系統裡是家常便飯。Laravel 提供了完整的失敗處理機制。

### failed_jobs Table

前面我們已經建立了 `failed_jobs` table。當 Job 的重試次數耗盡仍然失敗時，它會被記錄到這張 table，包含 Job 的完整資料和錯誤訊息。

### 在 Job 裡處理失敗

你可以在 Job class 裡定義 `failed()` 方法，在 Job 最終失敗時做一些處理：

```php
class ProcessGroupBuyConfirmation implements ShouldQueue
{
    // ...

    public function failed(\Throwable $exception): void
    {
        // 通知開發者
        Log::critical(&apos;團購確認處理失敗&apos;, [
            &apos;group_buy_id&apos; =&gt; $this-&gt;groupBuy-&gt;id,
            &apos;error&apos; =&gt; $exception-&gt;getMessage(),
        ]);

        // 通知開團主處理異常
        $this-&gt;groupBuy-&gt;organizer-&gt;notify(
            new ProcessingFailedNotification($this-&gt;groupBuy, $exception)
        );
    }
}
```

### 重試失敗的 Job

```bash
# 查看所有失敗的 Job
php artisan queue:failed

# 重試特定 Job
php artisan queue:retry &lt;job-id&gt;

# 重試所有失敗的 Job
php artisan queue:retry all

# 刪除特定失敗 Job
php artisan queue:forget &lt;job-id&gt;

# 清空所有失敗 Job
php artisan queue:flush
```

### 退避策略的最佳實踐

不要用固定的重試間隔。如果 SMTP Server 掛了，每 5 秒重試一次只會浪費資源。用**指數退避**（Exponential Backoff）：

```php
public function backoff(): array
{
    return [10, 60, 300]; // 10 秒、1 分鐘、5 分鐘
}
```

如果 Job 涉及第三方 API 呼叫，加上 Rate Limiting middleware：

```php
use Illuminate\Queue\Middleware\RateLimited;

public function middleware(): array
{
    return [
        new RateLimited(&apos;external-api&apos;),
    ];
}
```

在 `AppServiceProvider` 裡定義 rate limiter：

```php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;

public function boot(): void
{
    RateLimiter::for(&apos;external-api&apos;, function (object $job) {
        return Limit::perMinute(30);
    });
}
```

### Laravel Horizon 簡介

當你的 Queue 規模成長到需要監控時，[Laravel Horizon](https://laravel.com/docs/horizon) 是官方提供的 Redis Queue 儀表板。它提供：

- 即時監控 Job 的吞吐量和執行時間
- 查看失敗 Job 的詳細錯誤
- 自動調整 worker 數量
- 基於 tag 的 Job 搜尋和過濾

安裝很簡單：

```bash
composer require laravel/horizon
php artisan horizon:install
php artisan horizon
```

然後在瀏覽器開啟 `/horizon` 就能看到漂亮的監控介面。在「揪好買」正式上線後，Horizon 會是你觀察系統健康狀況的重要工具。不過在開發階段，用 `php artisan queue:work` 就夠了。

## 實作：揪好買的通知系統

把前面學到的全部串起來。以下是「揪好買」在團購成團時的完整通知流程：

### 第一步：定義 Event

```php
// app/Events/GroupBuyConfirmed.php
&lt;?php

namespace App\Events;

use App\Models\GroupBuy;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class GroupBuyConfirmed
{
    use Dispatchable, SerializesModels;

    public function __construct(
        public readonly GroupBuy $groupBuy,
    ) {}
}
```

### 第二步：建立三個 Listener

**Listener 1：通知所有跟團者**

```php
// app/Listeners/SendConfirmationToParticipants.php
&lt;?php

namespace App\Listeners;

use App\Events\GroupBuyConfirmed;
use App\Notifications\GroupBuyConfirmedNotification;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Notification;

class SendConfirmationToParticipants implements ShouldQueue
{
    public string $queue = &apos;notifications&apos;;

    public function handle(GroupBuyConfirmed $event): void
    {
        Notification::send(
            $event-&gt;groupBuy-&gt;participants,
            new GroupBuyConfirmedNotification($event-&gt;groupBuy)
        );
    }
}
```

**Listener 2：寄摘要報告給開團主**

```php
// app/Listeners/SendOrganizerSummary.php
&lt;?php

namespace App\Listeners;

use App\Events\GroupBuyConfirmed;
use App\Notifications\OrganizerSummaryNotification;
use Illuminate\Contracts\Queue\ShouldQueue;

class SendOrganizerSummary implements ShouldQueue
{
    public string $queue = &apos;notifications&apos;;

    public function handle(GroupBuyConfirmed $event): void
    {
        $groupBuy = $event-&gt;groupBuy;

        $groupBuy-&gt;organizer-&gt;notify(
            new OrganizerSummaryNotification($groupBuy)
        );
    }
}
```

**Listener 3：更新統計資料（同步）**

```php
// app/Listeners/UpdateGroupBuyStats.php
&lt;?php

namespace App\Listeners;

use App\Events\GroupBuyConfirmed;

class UpdateGroupBuyStats
{
    public function handle(GroupBuyConfirmed $event): void
    {
        $groupBuy = $event-&gt;groupBuy;

        $groupBuy-&gt;update([
            &apos;participant_count&apos; =&gt; $groupBuy-&gt;participants()-&gt;count(),
            &apos;total_amount&apos; =&gt; $groupBuy-&gt;orders()-&gt;sum(&apos;amount&apos;),
            &apos;confirmed_at&apos; =&gt; now(),
        ]);
    }
}
```

### 第三步：在 Service 裡觸發 Event

```php
// app/Services/GroupBuyService.php
&lt;?php

namespace App\Services;

use App\Events\GroupBuyConfirmed;
use App\Models\GroupBuy;

class GroupBuyService
{
    public function confirm(GroupBuy $groupBuy): void
    {
        // 確認條件檢查
        throw_unless(
            $groupBuy-&gt;canBeConfirmed(),
            \DomainException::class,
            &apos;此團購不符合成團條件&apos;
        );

        $groupBuy-&gt;update([&apos;status&apos; =&gt; &apos;confirmed&apos;]);

        // 就這一行，所有後續動作都會自動觸發
        event(new GroupBuyConfirmed($groupBuy));
    }
}
```

&gt; **如果這段被包在 transaction 裡，這個 race condition 會咬你**
&gt;
&gt; 上面的 `confirm()` 沒包 transaction 還算安全，但實務上你很可能會把「更新狀態 + 寫幾張關聯表」一起包進 `DB::transaction()`。問題來了：Listener 用 `SerializesModels` 只存了 model 的 ID，到 worker 端會重新 `find()` 一次回 DB 撈。Redis worker 很快，可能在你的 transaction 還沒 commit 的瞬間就把 job 撈走執行了——這時 DB 裡那筆 `confirmed` 資料還在你這個連線的交易裡，worker 看不到，直接吃 `ModelNotFoundException`。更陰險的是它不一定每次都中，本機跑沒事、上線高併發才偶發，超難重現。
&gt;
&gt; 解法是叫 job 等 commit 完再派：dispatch 時接 `-&gt;afterCommit()`，或在 `config/queue.php` 對應的 connection 設 `&apos;after_commit&apos; =&gt; true` 一勞永逸。`SerializesModels` 幫你省記憶體、避免資料過時，代價就是這個時序陷阱，兩件事是同一個機制的一體兩面，得一起記。

整個流程是這樣的：

```text
Controller 呼叫 GroupBuyService::confirm()
    ├── 更新 status = confirmed（同步）
    └── dispatch GroupBuyConfirmed event
            ├── SendConfirmationToParticipants（Queue → 寄 Email + 存站內訊息）
            ├── SendOrganizerSummary（Queue → 寄開團主摘要信）
            └── UpdateGroupBuyStats（同步 → 更新統計欄位）
```

### 第四步：截止提醒通知（Scheduled）

除了成團通知，「揪好買」還需要在截止前提醒跟團者。這個用 Laravel 的 Scheduler 搭配 Notification：

```php
// app/Notifications/GroupBuyDeadlineReminder.php
&lt;?php

namespace App\Notifications;

use App\Models\GroupBuy;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

class GroupBuyDeadlineReminder extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public readonly GroupBuy $groupBuy,
    ) {}

    public function via(object $notifiable): array
    {
        return [&apos;mail&apos;, &apos;database&apos;];
    }

    public function toMail(object $notifiable): MailMessage
    {
        $hoursLeft = (int) now()-&gt;diffInHours($this-&gt;groupBuy-&gt;deadline, true); // Carbon 3（Laravel 11/12）的 diffInHours 預設回傳帶正負號的 float，第二參數 $absolute=true 取絕對值並 cast int 確保顯示正確小時數

        return (new MailMessage)
            -&gt;subject(&quot;⏰ 團購「{$this-&gt;groupBuy-&gt;title}」即將截止&quot;)
            -&gt;line(&quot;你參加的團購還有 {$hoursLeft} 小時就要截止了。&quot;)
            -&gt;line(&quot;目前人數：{$this-&gt;groupBuy-&gt;participants()-&gt;count()} / {$this-&gt;groupBuy-&gt;min_participants}&quot;)
            -&gt;action(&apos;查看團購&apos;, route(&apos;group-buys.show&apos;, $this-&gt;groupBuy))
            -&gt;line(&apos;趕快分享給朋友一起揪團吧！&apos;);
    }

    public function toDatabase(object $notifiable): array
    {
        return [
            &apos;group_buy_id&apos; =&gt; $this-&gt;groupBuy-&gt;id,
            &apos;group_buy_title&apos; =&gt; $this-&gt;groupBuy-&gt;title,
            &apos;message&apos; =&gt; &quot;團購「{$this-&gt;groupBuy-&gt;title}」即將截止&quot;,
            &apos;type&apos; =&gt; &apos;deadline_reminder&apos;,
        ];
    }
}
```

在 `routes/console.php` 裡設定排程：

```php
use App\Models\GroupBuy;
use App\Notifications\GroupBuyDeadlineReminder;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Schedule;

Schedule::call(function () {
    // 找出 24 小時內即將截止的團購
    $groupBuys = GroupBuy::where(&apos;status&apos;, &apos;open&apos;)
        -&gt;whereBetween(&apos;deadline&apos;, [now(), now()-&gt;addHours(24)])
        -&gt;whereNull(&apos;reminder_sent_at&apos;)
        -&gt;get();

    foreach ($groupBuys as $groupBuy) {
        Notification::send(
            $groupBuy-&gt;participants,
            new GroupBuyDeadlineReminder($groupBuy)
        );

        $groupBuy-&gt;update([&apos;reminder_sent_at&apos; =&gt; now()]);
    }
})-&gt;hourly()-&gt;name(&apos;group-buy-deadline-reminders&apos;);
```

### 啟動 Worker

所有 Queue 裡的 Job 需要一個 worker process 來處理。開發時直接在終端機執行：

```bash
# 啟動 worker
php artisan queue:work

# 指定處理特定 queue
php artisan queue:work --queue=notifications,default

# 處理一個 job 後就停止（適合測試）
php artisan queue:work --once

# 設定記憶體限制和超時
php artisan queue:work --memory=256 --timeout=120
```

&gt; **注意：** `queue:work` 是長時間執行的 process。在開發時改了 Job 的程式碼，需要重啟 worker 才會載入新的程式碼。可以用 `php artisan queue:restart` 優雅地重啟，或者在開發時用 `queue:listen`——它每次都會重新載入程式碼（但效能較差，僅限開發使用）。

在正式環境，你會用 process manager（如 Supervisor）來確保 worker 持續運行：

```ini
# /etc/supervisor/conf.d/jiuhaobuy-worker.conf
[program:jiuhaobuy-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/jiuhaobuy/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/jiuhaobuy/storage/logs/worker.log
stopwaitsecs=3600
```

## 小結：非同步思維讓應用更健壯

這一章我們學了三個核心概念，各自解決不同的問題：

- **Queue**——解決「何時做」：把耗時任務丟到背景，使用者不用等。Driver 從開發用的 `database` 到正式環境的 `redis`，切換只需改一行 `.env`。
- **Event/Listener**——解決「誰做什麼」：用事件驅動的方式解耦業務邏輯。新增需求只要加 Listener，不用改原本的程式碼。搭配 `ShouldQueue` 就能讓 Listener 在背景執行。
- **Notification**——解決「怎麼通知」：一個 class 搞定 Email、站內訊息、SMS 等多種管道。搭配 Queue 在背景發送，搭配 Database driver 實作站內訊息。

在「揪好買」裡，這三者的組合是：Controller 呼叫 Service → Service dispatch Event → Listener 在背景透過 Notification 發送多管道通知。整個流程清晰、解耦、可擴展。

現在你的後端已經能優雅地處理背景任務和通知了。但你的系統目前只服務網頁使用者。[下一章](/blog/laravel-guide-api-sanctum-rest/)，我們要把揪好買的核心功能包裝成 RESTful API，用 Sanctum 做 Token 認證，讓手機 App 和 LINE Bot 也能開團、跟團、查詢訂單——同一套後端，服務所有人。</content:encoded><media:content url="https://bobochen.dev/_astro/cover.DxBTuiJw.webp" medium="image"/><category>PHP</category><category>Laravel</category><category>Queue</category><category>Event</category><category>Notification</category><category>Mail</category><enclosure url="https://bobochen.dev/_astro/cover.DxBTuiJw.webp" length="0" type="image/png"/></item><item><title>訂單與金流：成團後用 Cashier 串接 Stripe 收款</title><link>https://bobochen.dev/blog/laravel-guide-orders-stripe-cashier/</link><guid isPermaLink="true">https://bobochen.dev/blog/laravel-guide-orders-stripe-cashier/</guid><description>用 Laravel Cashier 串接 Stripe，從成團確認到收款的完整結帳流程：Checkout Session、Webhook 簽名驗證、訂單狀態機與 DB Transaction，含 Stripe 測試模式與本地 Webhook 測試。</description><pubDate>Tue, 29 Apr 2025 00:00:00 GMT</pubDate><content:encoded>成團了，然後呢？錢要怎麼收？這是每個電商類專案最讓人緊張的環節。金流處理不像一般的 CRUD，有幾個雷你得事先知道：

- **信用卡號不能自己存**——PCI DSS 合規不是鬧著玩的
- **付款失敗要能重試**——使用者的卡可能被拒、餘額不足
- **Webhook 要正確處理**——Stripe 的回呼打來時，你的程式碼必須正確回應
- **訂單狀態要明確流轉**——從「建立 → 付款中 → 已付款 → 已完成」，每個轉換都要受控

任何一個環節出錯，輕則使用者體驗差，重則真的會出財務問題。

好在 Laravel Cashier 把 Stripe 整合這件事做得非常優雅。它幫你處理了客戶（Customer）建立、Checkout Session 流程、Webhook 驗證與分發、訂閱管理這些繁瑣的底層工作，讓你可以專注在自己的業務邏輯上。你不需要自己去讀 Stripe API 文件的每一頁——Cashier 已經幫你封裝好了最常用的操作。當然，理解底層原理還是很重要的，所以這一章我們會先搞懂金流處理的基本觀念，再一步步把 Cashier 接進來。

在「揪好買」裡，金流的觸發時機跟一般電商不一樣——不是使用者按下「購買」就收錢，而是等到成團確認後，才統一向所有跟團者收款。這代表我們需要一個清楚的訂單狀態機，還要處理「成團了但某個人付款失敗」的 edge case。這一章會完整走過這個流程。

## 金流處理的基本觀念：為什麼 Stripe 不讓你自己存信用卡號

讓我先講一個會讓你嚇出冷汗的事實：如果你自己在資料庫裡存信用卡號，你需要通過 **PCI DSS**（Payment Card Industry Data Security Standard）合規認證。這個認證要求包含——但不限於——專用的加密硬體、年度安全稽核、滲透測試、嚴格的存取控制。完整的 Level 1 合規認證每年的費用可以到幾十萬美金。

所以答案很簡單：**不要自己存信用卡號**。讓專業的支付處理商（Stripe、綠界 ECPay、藍新 NewebPay）來處理。

金流的核心概念是**轉嫁責任**。整個付款流程長這樣：

```
1. 使用者在你的網站按下「付款」
2. 你的伺服器向 Stripe 建立一個 Checkout Session
3. 使用者被導向到 Stripe 的付款頁面（hosted page）
4. 使用者在 Stripe 的頁面輸入信用卡資訊
5. Stripe 處理付款
6. Stripe 透過 webhook 通知你的伺服器「付款成功了」
7. 你的伺服器更新訂單狀態
```

注意：信用卡資訊**從頭到尾都不經過你的伺服器**。使用者是在 Stripe 的頁面上輸入的。你的伺服器只收到「付款成功」或「付款失敗」的通知——不會碰到任何卡號資訊。這就是 Stripe Checkout 的精髓。

### 跨服務對照

| 概念          | Stripe           | 綠界 ECPay          | 藍新 NewebPay       |
| ------------- | ---------------- | ------------------- | ------------------- |
| Hosted 付款頁 | Checkout Session | 付款頁（AIO）       | MPG 多功能收款      |
| 付款結果通知  | Webhook          | 付款完成通知        | 背景通知 NotifyURL  |
| 客戶端套件    | Stripe.js        | N/A                 | N/A                 |
| Laravel 套件  | Laravel Cashier  | 自行串接 / 社群套件 | 自行串接 / 社群套件 |

&gt; **為什麼選 Stripe？（先講一個會卡死你的前提）** 本章用 Stripe，純粹因為它有官方 Laravel 套件（Cashier）、文件最齊全、測試工具最好用，最適合「學金流概念」。但有件事我必須先講清楚，免得你照做到最後才發現收不到錢：Stripe 目前不支援台灣境內公司直接開戶收款（台灣不在它的支援國家清單內），真要收到台幣帳戶，得繞道去註冊美國公司、辦 EIN 那一整套——對一個只想做台灣團購平台的人來說，這成本不合理。所以請把這章當成「拿 Stripe 學概念」，真要在台灣上線收款，請改用綠界 ECPay 或藍新 NewebPay，它們才支援台灣公司行號跟在地金流（ATM、超商代碼、Line Pay）。好消息是：這裡學到的 webhook 驗證、訂單狀態機、DB transaction 邏輯，換成台灣金流幾乎照搬，所以這章不算白學——只是別把「學習情境」當成「能上線收錢」。

## Laravel Cashier 與 Stripe 整合

Laravel Cashier 是 Laravel 官方維護的 Stripe 整合套件。它封裝了最常用的 Stripe 操作——建立 Customer、Checkout Session、處理 Webhook——讓你用優雅的 PHP 語法完成金流串接。

### 安裝

```bash
composer require laravel/cashier
```

### 執行 Migration

Cashier 的 migration 需要先發佈到 `database/migrations/`（Laravel 11+ 起 Cashier 不再自動載入 migration），再執行 migrate，才會在你的 `users` 表加上 Stripe 相關的欄位：

```bash
php artisan vendor:publish --tag=&quot;cashier-migrations&quot;
php artisan migrate
```

這會新增以下欄位到 `users` 表：

| 欄位            | 用途                                    |
| --------------- | --------------------------------------- |
| `stripe_id`     | 使用者在 Stripe 的 Customer ID          |
| `pm_type`       | 預設付款方式類型（visa, mastercard...） |
| `pm_last_four`  | 信用卡末四碼                            |
| `trial_ends_at` | 試用期結束時間（訂閱制用）              |

### 設定 Billable Trait

在 User Model 上加入 `Billable` trait：

```php
&lt;?php

namespace App\Models;

use Laravel\Cashier\Billable;
use Illuminate\Foundation\Auth\User as Authenticatable;

class User extends Authenticatable
{
    use Billable;

    // ... 其他程式碼
}
```

`Billable` trait 賦予 User Model 一系列 Stripe 相關的方法——`checkout()`、`charge()`、`subscription()` 等等。加了這一行，你的 User 就能直接跟 Stripe 互動。

## Stripe 帳號設定與測試模式

### 建立 Stripe 帳號

到 [stripe.com](https://stripe.com) 註冊帳號。不需要填信用卡，開發階段全程用測試模式。

在 Dashboard 的 **Developers → API keys** 取得你的測試金鑰：

- **Publishable key**：`pk_test_...`（前端用，可以公開）
- **Secret key**：`sk_test_...`（後端用，絕對不能公開）

### 設定 .env

```bash
STRIPE_KEY=pk_test_51ABC...
STRIPE_SECRET=sk_test_51ABC...
STRIPE_WEBHOOK_SECRET=whsec_...
```

&gt; **千萬不要把 Secret key 提交到 Git。** `.env` 已經在 `.gitignore` 裡了，但還是提醒一下——這種 key 外流的事件每個月都在發生。

### Stripe 測試卡號

Stripe 提供一整套測試用的卡號，讓你不用刷真的信用卡就能測試各種場景：

| 卡號                  | 行為                   |
| --------------------- | ---------------------- |
| `4242 4242 4242 4242` | 付款成功               |
| `4000 0000 0000 3220` | 需要 3D Secure 驗證    |
| `4000 0000 0000 0002` | 付款被拒（卡片被拒絕） |
| `4000 0000 0000 9995` | 付款失敗（餘額不足）   |

到期日填任何未來的日期，CVC 填任意三碼數字。

### Stripe CLI：本地測試 Webhook

Webhook 是 Stripe 打到**你的伺服器**的 HTTP 請求。但開發階段你的電腦通常在 NAT 後面，Stripe 打不到你。Stripe CLI 幫你解決這個問題：

```bash
# 安裝（macOS）
brew install stripe/stripe-cli/stripe

# 登入
stripe login

# 把 Stripe 的 webhook 事件轉發到你的本地伺服器
stripe listen --forward-to localhost:8000/stripe/webhook
```

執行後，CLI 會印出一個 `whsec_...` 的 webhook signing secret，把它填到 `.env` 的 `STRIPE_WEBHOOK_SECRET`。

開另一個終端機觸發測試事件：

```bash
stripe trigger checkout.session.completed
```

你會看到 CLI 印出事件轉發的紀錄，而你的 Laravel 應用也會收到對應的 webhook。

## 訂單 Model 設計：Order 與 OrderItem

在接 Stripe 之前，先把訂單的資料結構搞定。揪好買的訂單跟一般電商略有不同：一個訂單對應一個使用者在一個團購裡的跟團記錄。

### 建立 Migration 與 Model

```bash
php artisan make:model Order -mf
php artisan make:model OrderItem -mf
```

### Order Migration

```php
&lt;?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create(&apos;orders&apos;, function (Blueprint $table) {
            $table-&gt;id();
            $table-&gt;foreignId(&apos;user_id&apos;)-&gt;constrained()-&gt;cascadeOnDelete();
            $table-&gt;foreignId(&apos;group_buy_id&apos;)-&gt;constrained()-&gt;cascadeOnDelete();
            $table-&gt;integer(&apos;total&apos;);                         // 總金額（分）
            $table-&gt;string(&apos;status&apos;)-&gt;default(&apos;pending&apos;);     // pending, paid, failed, refunded
            $table-&gt;string(&apos;stripe_checkout_session_id&apos;)-&gt;nullable();
            $table-&gt;string(&apos;stripe_payment_intent_id&apos;)-&gt;nullable();
            $table-&gt;timestamp(&apos;paid_at&apos;)-&gt;nullable();
            $table-&gt;timestamps();

            $table-&gt;unique([&apos;user_id&apos;, &apos;group_buy_id&apos;]);      // 一個使用者在一個團購只有一筆訂單
        });
    }

    public function down(): void
    {
        Schema::dropIfExists(&apos;orders&apos;);
    }
};
```

### OrderItem Migration

```php
return new class extends Migration
{
    public function up(): void
    {
        Schema::create(&apos;order_items&apos;, function (Blueprint $table) {
            $table-&gt;id();
            $table-&gt;foreignId(&apos;order_id&apos;)-&gt;constrained()-&gt;cascadeOnDelete();
            $table-&gt;string(&apos;product_name&apos;);
            $table-&gt;integer(&apos;quantity&apos;);
            $table-&gt;integer(&apos;unit_price&apos;);     // 單價（分）
            $table-&gt;integer(&apos;subtotal&apos;);       // 小計（分）= quantity * unit_price
            $table-&gt;timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists(&apos;order_items&apos;);
    }
};
```

### OrderStatus Enum

```php
&lt;?php

// app/Enums/OrderStatus.php
namespace App\Enums;

enum OrderStatus: string
{
    case Pending = &apos;pending&apos;;
    case Paid = &apos;paid&apos;;
    case Failed = &apos;failed&apos;;
    case Refunded = &apos;refunded&apos;;
    case Shipping = &apos;shipping&apos;;
    case Completed = &apos;completed&apos;;

    public function label(): string
    {
        return match ($this) {
            self::Pending =&gt; &apos;待付款&apos;,
            self::Paid =&gt; &apos;已付款&apos;,
            self::Failed =&gt; &apos;付款失敗&apos;,
            self::Refunded =&gt; &apos;已退款&apos;,
            self::Shipping =&gt; &apos;出貨中&apos;,
            self::Completed =&gt; &apos;已完成&apos;,
        };
    }

    public function color(): string
    {
        return match ($this) {
            self::Pending =&gt; &apos;yellow&apos;,
            self::Paid =&gt; &apos;green&apos;,
            self::Failed =&gt; &apos;red&apos;,
            self::Refunded =&gt; &apos;gray&apos;,
            self::Shipping =&gt; &apos;blue&apos;,
            self::Completed =&gt; &apos;green&apos;,
        };
    }
}
```

### Model 定義

```php
&lt;?php

// app/Models/Order.php
namespace App\Models;

use App\Enums\OrderStatus;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Order extends Model
{
    protected $fillable = [
        &apos;user_id&apos;,
        &apos;group_buy_id&apos;,
        &apos;total&apos;,
        &apos;status&apos;,
        &apos;stripe_checkout_session_id&apos;,
        &apos;stripe_payment_intent_id&apos;,
        &apos;paid_at&apos;,
    ];

    protected function casts(): array
    {
        return [
            &apos;status&apos; =&gt; OrderStatus::class,
            &apos;total&apos; =&gt; &apos;integer&apos;,
            &apos;paid_at&apos; =&gt; &apos;datetime&apos;,
        ];
    }

    // ── 關聯 ──

    public function user(): BelongsTo
    {
        return $this-&gt;belongsTo(User::class);
    }

    public function groupBuy(): BelongsTo
    {
        return $this-&gt;belongsTo(GroupBuy::class);
    }

    public function items(): HasMany
    {
        return $this-&gt;hasMany(OrderItem::class);
    }
}
```

```php
&lt;?php

// app/Models/OrderItem.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class OrderItem extends Model
{
    protected $fillable = [
        &apos;order_id&apos;,
        &apos;product_name&apos;,
        &apos;quantity&apos;,
        &apos;unit_price&apos;,
        &apos;subtotal&apos;,
    ];

    protected function casts(): array
    {
        return [
            &apos;quantity&apos; =&gt; &apos;integer&apos;,
            &apos;unit_price&apos; =&gt; &apos;integer&apos;,
            &apos;subtotal&apos; =&gt; &apos;integer&apos;,
        ];
    }

    public function order(): BelongsTo
    {
        return $this-&gt;belongsTo(Order::class);
    }
}
```

在 User Model 加上關聯：

```php
// app/Models/User.php
public function orders(): HasMany
{
    return $this-&gt;hasMany(Order::class);
}
```

### ER 關係圖

```
users ──────&lt; orders &gt;────── group_buys
                │
                │
                └──────&lt; order_items
```

- 一個 user 有多個 orders（一對多）
- 一個 group_buy 有多個 orders（一對多）
- 一個 order 有多個 order_items（一對多）

## Database Transaction：確保資料一致性

建立訂單的時候，你需要同時做好幾件事：建立 Order、建立 OrderItem、扣減庫存（如果有的話）。這些動作必須**全部成功**或**全部失敗**——不能出現「Order 建好了但 OrderItem 沒建」的殘缺狀態。

這就是 Database Transaction 的用途。

### DB::transaction()

```php
use Illuminate\Support\Facades\DB;

$order = DB::transaction(function () use ($user, $groupBuy, $quantity) {
    // 建立訂單
    $order = Order::create([
        &apos;user_id&apos; =&gt; $user-&gt;id,
        &apos;group_buy_id&apos; =&gt; $groupBuy-&gt;id,
        &apos;total&apos; =&gt; $groupBuy-&gt;price_per_unit * $quantity,
        &apos;status&apos; =&gt; &apos;pending&apos;,
    ]);

    // 建立訂單項目
    $order-&gt;items()-&gt;create([
        &apos;product_name&apos; =&gt; $groupBuy-&gt;product_name,
        &apos;quantity&apos; =&gt; $quantity,
        &apos;unit_price&apos; =&gt; $groupBuy-&gt;price_per_unit,
        &apos;subtotal&apos; =&gt; $groupBuy-&gt;price_per_unit * $quantity,
    ]);

    return $order;
});
```

如果 `DB::transaction()` 裡的任何一步拋出例外，所有資料庫操作都會被**回滾**（rollback）——就像什麼都沒發生過一樣。

### 失敗場景

想像這個情況：`Order::create()` 成功了，但 `$order-&gt;items()-&gt;create()` 因為某個驗證錯誤失敗了。如果沒有 Transaction：

- 資料庫裡有一筆 Order，但沒有對應的 OrderItem
- 使用者看到「訂單已建立」但內容是空的
- 你需要手動清理殘餘資料

有了 Transaction，整個操作被回滾，資料庫維持乾淨。

### 跨框架對照

| 框架      | Transaction 語法                          |
| --------- | ----------------------------------------- |
| Laravel   | `DB::transaction(fn () =&gt; ...)`           |
| Django    | `with transaction.atomic():`              |
| Sequelize | `sequelize.transaction(async (t) =&gt; ...)` |
| Prisma    | `prisma.$transaction([...])`              |

&gt; **什麼時候該用 Transaction？** 只要你在一個操作裡有**兩個以上**的資料庫寫入，而且它們必須同時成功或失敗，就該用 Transaction。訂單建立、轉帳、庫存異動——這些都是經典場景。

## Stripe Checkout Session 流程

Checkout Session 是 Stripe 的 hosted 付款頁面。你在後端建立一個 Session，Stripe 回傳一個 URL，你把使用者導過去——Stripe 處理所有付款流程，完成後把使用者導回你的網站。

### 建立 Checkout Session

```php
&lt;?php

namespace App\Http\Controllers;

use App\Models\Order;
use Illuminate\Http\Request;

class CheckoutController extends Controller
{
    public function create(Order $order)
    {
        // 確認訂單屬於當前使用者，且狀態是 pending
        $this-&gt;authorize(&apos;pay&apos;, $order);

        $checkout = $order-&gt;user-&gt;checkout(
            // line items：顯示在 Stripe 付款頁上的項目
            $order-&gt;items-&gt;map(fn ($item) =&gt; [
                &apos;price_data&apos; =&gt; [
                    &apos;currency&apos; =&gt; &apos;twd&apos;,
                    &apos;product_data&apos; =&gt; [
                        &apos;name&apos; =&gt; $item-&gt;product_name,
                    ],
                    &apos;unit_amount&apos; =&gt; $item-&gt;unit_price,  // 以「分」為單位（TWD 不需要換算）
                ],
                &apos;quantity&apos; =&gt; $item-&gt;quantity,
            ])-&gt;toArray(),
            // session options
            [
                &apos;success_url&apos; =&gt; route(&apos;checkout.success&apos;, [&apos;order&apos; =&gt; $order-&gt;id]) . &apos;?session_id={CHECKOUT_SESSION_ID}&apos;,
                &apos;cancel_url&apos; =&gt; route(&apos;checkout.cancel&apos;, [&apos;order&apos; =&gt; $order-&gt;id]),
                &apos;metadata&apos; =&gt; [
                    &apos;order_id&apos; =&gt; $order-&gt;id,    // 關鍵：在 webhook 裡用這個找回訂單
                ],
            ]
        );

        // 記錄 Session ID 到訂單
        $order-&gt;update([
            &apos;stripe_checkout_session_id&apos; =&gt; $checkout-&gt;id,
        ]);

        return redirect($checkout-&gt;url);
    }
}
```

這裡有幾個重點：

1. **`unit_amount` 的單位**：Stripe 用的是貨幣的最小單位。對 USD 來說 `1000` = $10.00（以 cent 為單位）；對 TWD 來說 `1000` 就是 NT$1,000（因為台幣沒有「分」）。如果你的資料庫存的是台幣整數，直接給就好。
2. **`metadata`**：自訂資料，Stripe 會在 webhook 事件裡原封不動回傳給你。我們放了 `order_id`，等收到 webhook 時就能快速找到對應的訂單。
3. **`success_url` 和 `cancel_url`**：使用者付款成功/取消後被導回的網址。`{CHECKOUT_SESSION_ID}` 是 Stripe 的 placeholder，會被替換成真正的 Session ID。

### 成功與取消頁面

```php
// routes/web.php
Route::middleware([&apos;auth&apos;])-&gt;group(function () {
    Route::get(&apos;/checkout/{order}&apos;, [CheckoutController::class, &apos;create&apos;])-&gt;name(&apos;checkout.create&apos;);
    Route::get(&apos;/checkout/{order}/success&apos;, [CheckoutController::class, &apos;success&apos;])-&gt;name(&apos;checkout.success&apos;);
    Route::get(&apos;/checkout/{order}/cancel&apos;, [CheckoutController::class, &apos;cancel&apos;])-&gt;name(&apos;checkout.cancel&apos;);
});
```

```php
// CheckoutController.php

public function success(Request $request, Order $order)
{
    // 注意：不要在這裡更新訂單狀態！
    // 這只是使用者看到的頁面，真正的狀態更新要靠 webhook。
    // 使用者可能直接複製這個 URL，不代表真的付款了。

    return view(&apos;checkout.success&apos;, [
        &apos;order&apos; =&gt; $order-&gt;load(&apos;items&apos;, &apos;groupBuy&apos;),
    ]);
}

public function cancel(Request $request, Order $order)
{
    return view(&apos;checkout.cancel&apos;, [
        &apos;order&apos; =&gt; $order-&gt;load(&apos;groupBuy&apos;),
    ]);
}
```

&gt; **非常重要：** 絕對不要在 `success_url` 的 callback 裡直接把訂單標記為已付款。使用者可以自己在瀏覽器裡輸入 success URL 而不需要真的付款。**唯一可信的付款確認來源是 Stripe 的 webhook。**

## Webhook 處理：Stripe 回呼你的伺服器

Webhook 的概念很簡單：當 Stripe 那邊發生了某個事件（付款成功、付款失敗、退款完成），Stripe 會主動發一個 HTTP POST 請求到你事先設定好的 URL。你的伺服器接到這個請求，就能做對應的處理。

### 為什麼需要 Webhook？

你可能會想：「使用者付完款被導回 success URL，我在那裡處理不就好了？」

不行。有好幾個原因：

1. **使用者可能關掉瀏覽器**——付完款但沒被導回你的網站。
2. **Success URL 可以被偽造**——任何人都能在瀏覽器裡輸入那個 URL。
3. **延遲付款**——某些付款方式（銀行轉帳、超商繳費）不是即時完成的。
4. **Stripe 保證投遞**——如果你的伺服器暫時掛了，Stripe 會重試。

### Cashier 的 Webhook 路由

Cashier 已經幫你註冊了一個 webhook 路由：

```php
// 自動註冊在 POST /stripe/webhook
```

你需要在 Stripe Dashboard 裡設定 webhook endpoint，指向你的 `https://your-domain.com/stripe/webhook`。

&gt; **CSRF 豁免：** Stripe 的 webhook 請求不會帶 CSRF token（因為是 Stripe 伺服器發的，不是瀏覽器）。Cashier 已經自動把 `/stripe/webhook` 排除在 CSRF 驗證之外，你不需要手動處理。

### 監聽 Checkout 完成事件

Cashier 會自動驗證 webhook 的簽名（確認是 Stripe 發的，不是惡意攻擊者偽造的），然後把事件分發出來。你只需要監聽對應的事件：

```php
&lt;?php

// app/Listeners/HandleCheckoutSessionCompleted.php
namespace App\Listeners;

use App\Enums\OrderStatus;
use App\Models\Order;
use Laravel\Cashier\Events\WebhookReceived;

class HandleCheckoutSessionCompleted
{
    public function handle(WebhookReceived $event): void
    {
        // 只處理 checkout.session.completed 事件
        if ($event-&gt;payload[&apos;type&apos;] !== &apos;checkout.session.completed&apos;) {
            return;
        }

        $session = $event-&gt;payload[&apos;data&apos;][&apos;object&apos;];
        $orderId = $session[&apos;metadata&apos;][&apos;order_id&apos;] ?? null;

        if (! $orderId) {
            return;
        }

        $order = Order::find($orderId);

        if (! $order || $order-&gt;status !== OrderStatus::Pending) {
            return;
        }

        // 走狀態機，而不是直接 update —— 與前面定義的 markAsPaid() 一致
        $order-&gt;markAsPaid($session[&apos;payment_intent&apos;]);
    }
}
```

### 註冊 Listener

在 `AppServiceProvider` 的 `boot()` 方法裡，或用 `EventServiceProvider`：

```php
// app/Providers/AppServiceProvider.php
use App\Listeners\HandleCheckoutSessionCompleted;
use Illuminate\Support\Facades\Event;
use Laravel\Cashier\Events\WebhookReceived;

public function boot(): void
{
    Event::listen(WebhookReceived::class, HandleCheckoutSessionCompleted::class);
}
```

### Webhook 簽名驗證

Stripe 在每個 webhook 請求的 header 裡帶了一個簽名（`Stripe-Signature`）。Cashier 會用你 `.env` 裡的 `STRIPE_WEBHOOK_SECRET` 來驗證這個簽名，確保請求真的是 Stripe 發的。

如果驗證失敗（簽名不對），Cashier 會回傳 403，webhook 內容不會被處理。這是防止惡意攻擊者偽造 webhook 的關鍵安全機制——沒有這一層，任何人都能假裝 Stripe 打你的 webhook endpoint，偽造「付款成功」的通知。

## 訂單狀態機：從建立到完成

訂單在它的生命週期裡會經歷不同的狀態。重點是：**不是每個狀態都能轉換到任何其他狀態**。你不能把「已退款」的訂單變成「待付款」，也不能把「付款失敗」直接跳到「已完成」。

### 合法的狀態轉換

```
pending ──→ paid ──→ shipping ──→ completed
   │          │
   │          └──→ refunded
   │
   └──→ failed ──→ pending（重新付款）
```

### 在 Order Model 上實作狀態轉換

```php
&lt;?php

// app/Models/Order.php 加入以下方法
use App\Enums\OrderStatus;

class Order extends Model
{
    // ... 前面的程式碼 ...

    // ── 狀態轉換 ──

    private const ALLOWED_TRANSITIONS = [
        &apos;pending&apos; =&gt; [&apos;paid&apos;, &apos;failed&apos;],
        &apos;paid&apos; =&gt; [&apos;shipping&apos;, &apos;refunded&apos;],
        &apos;failed&apos; =&gt; [&apos;pending&apos;],          // 重新付款
        &apos;shipping&apos; =&gt; [&apos;completed&apos;],
        &apos;completed&apos; =&gt; [],                // 終態
        &apos;refunded&apos; =&gt; [],                 // 終態
    ];

    public function transitionTo(OrderStatus $newStatus): void
    {
        $allowed = self::ALLOWED_TRANSITIONS[$this-&gt;status-&gt;value] ?? [];

        if (! in_array($newStatus-&gt;value, $allowed)) {
            throw new \InvalidArgumentException(
                &quot;無法將訂單從「{$this-&gt;status-&gt;label()}」轉換為「{$newStatus-&gt;label()}」&quot;
            );
        }

        $this-&gt;update([&apos;status&apos; =&gt; $newStatus]);
    }

    public function markAsPaid(string $paymentIntentId): void
    {
        $this-&gt;transitionTo(OrderStatus::Paid);

        $this-&gt;update([
            &apos;stripe_payment_intent_id&apos; =&gt; $paymentIntentId,
            &apos;paid_at&apos; =&gt; now(),
        ]);
    }

    public function markAsFailed(): void
    {
        $this-&gt;transitionTo(OrderStatus::Failed);
    }

    public function markAsShipping(): void
    {
        $this-&gt;transitionTo(OrderStatus::Shipping);
    }

    public function markAsCompleted(): void
    {
        $this-&gt;transitionTo(OrderStatus::Completed);
    }

    // ── 查詢 ──

    public function isPaid(): bool
    {
        return $this-&gt;status === OrderStatus::Paid;
    }

    public function canPay(): bool
    {
        return $this-&gt;status === OrderStatus::Pending
            || $this-&gt;status === OrderStatus::Failed;
    }
}
```

為什麼要搞這麼「囉嗦」的狀態機？因為**不合法的狀態轉換是訂單系統裡最常見的 bug**。你可能在某個 Controller 裡不小心把已退款的訂單又改成已付款，然後使用者收到錯誤的通知、財務報表數字對不上、客服接到一堆抱怨電話。把合法的轉換規則寫死在 Model 裡，任何不合法的操作都會直接拋例外——bug 在開發階段就被抓到，不會偷偷溜到正式環境。

## 實作：揪好買成團後收款流程

理論講完了，讓我們把所有東西串起來。揪好買的收款流程是這樣的：

```
1. 成團確認（[上一章的邏輯](/blog/laravel-guide-group-buy-logic-session/)）
2. 為每個跟團者建立 Order（DB::transaction）
3. 寄出付款連結給每個跟團者
4. 跟團者點連結 → 導向 Stripe Checkout
5. 付款完成 → Stripe 打 webhook → 更新訂單狀態
6. 所有人都付款了 → 團購狀態改為 completed
```

### Step 1：成團後批次建立訂單

```php
&lt;?php

// app/Services/GroupBuyService.php
namespace App\Services;

use App\Enums\OrderStatus;
use App\Models\GroupBuy;
use App\Models\Order;
use Illuminate\Support\Facades\DB;

class GroupBuyService
{
    /**
     * 成團確認：為所有跟團者建立訂單
     */
    public function confirm(GroupBuy $groupBuy): void
    {
        // 防呆：已經確認過的不能再確認
        if ($groupBuy-&gt;status !== &apos;open&apos;) {
            throw new \RuntimeException(&apos;此團購已非開放狀態，無法確認成團&apos;);
        }

        // 防呆：人數不夠不能成團
        if ($groupBuy-&gt;participants()-&gt;count() &lt; $groupBuy-&gt;min_participants) {
            throw new \RuntimeException(&apos;跟團人數未達最低門檻&apos;);
        }

        DB::transaction(function () use ($groupBuy) {
            // 更新團購狀態
            $groupBuy-&gt;update([&apos;status&apos; =&gt; &apos;confirmed&apos;]);

            // 為每個跟團者建立訂單
            foreach ($groupBuy-&gt;participants as $participant) {
                $quantity = $participant-&gt;pivot-&gt;quantity;

                $order = Order::create([
                    &apos;user_id&apos; =&gt; $participant-&gt;id,
                    &apos;group_buy_id&apos; =&gt; $groupBuy-&gt;id,
                    &apos;total&apos; =&gt; $groupBuy-&gt;price_per_unit * $quantity,
                    &apos;status&apos; =&gt; OrderStatus::Pending,
                ]);

                $order-&gt;items()-&gt;create([
                    &apos;product_name&apos; =&gt; $groupBuy-&gt;product_name,
                    &apos;quantity&apos; =&gt; $quantity,
                    &apos;unit_price&apos; =&gt; $groupBuy-&gt;price_per_unit,
                    &apos;subtotal&apos; =&gt; $groupBuy-&gt;price_per_unit * $quantity,
                ]);
            }
        });

        // 寄出付款通知（下一章用 Queue + Notification，詳見 /blog/laravel-guide-queues-events-notifications/）
        // event(new GroupBuyConfirmed($groupBuy));
    }
}
```

整個操作包在 `DB::transaction()` 裡——如果幫第五個人建立訂單的時候炸了，前四個人的訂單也會被回滾。不會出現「有些人有訂單有些人沒有」的混亂狀態。

### Step 2：CheckoutController 完整實作

```php
&lt;?php

// app/Http/Controllers/CheckoutController.php
namespace App\Http\Controllers;

use App\Models\Order;
use Illuminate\Http\Request;

class CheckoutController extends Controller
{
    /**
     * 導向 Stripe Checkout 付款頁
     */
    public function create(Order $order)
    {
        $user = auth()-&gt;user();

        // 確認是自己的訂單
        if ($order-&gt;user_id !== $user-&gt;id) {
            abort(403, &apos;這不是你的訂單&apos;);
        }

        // 確認訂單可以付款
        if (! $order-&gt;canPay()) {
            return redirect()-&gt;route(&apos;orders.show&apos;, $order)
                -&gt;with(&apos;error&apos;, &apos;此訂單目前無法付款&apos;);
        }

        $checkout = $user-&gt;checkout(
            $order-&gt;items-&gt;map(fn ($item) =&gt; [
                &apos;price_data&apos; =&gt; [
                    &apos;currency&apos; =&gt; &apos;twd&apos;,
                    &apos;product_data&apos; =&gt; [
                        &apos;name&apos; =&gt; $item-&gt;product_name,
                    ],
                    &apos;unit_amount&apos; =&gt; $item-&gt;unit_price,
                ],
                &apos;quantity&apos; =&gt; $item-&gt;quantity,
            ])-&gt;toArray(),
            [
                &apos;success_url&apos; =&gt; route(&apos;checkout.success&apos;, $order) . &apos;?session_id={CHECKOUT_SESSION_ID}&apos;,
                &apos;cancel_url&apos; =&gt; route(&apos;checkout.cancel&apos;, $order),
                &apos;metadata&apos; =&gt; [
                    &apos;order_id&apos; =&gt; $order-&gt;id,
                ],
            ]
        );

        $order-&gt;update([
            &apos;stripe_checkout_session_id&apos; =&gt; $checkout-&gt;id,
        ]);

        return redirect($checkout-&gt;url);
    }

    /**
     * 付款成功頁面（僅顯示用，真正的狀態更新靠 webhook）
     */
    public function success(Request $request, Order $order)
    {
        return view(&apos;checkout.success&apos;, [
            &apos;order&apos; =&gt; $order-&gt;load(&apos;items&apos;, &apos;groupBuy&apos;),
        ]);
    }

    /**
     * 使用者取消付款
     */
    public function cancel(Request $request, Order $order)
    {
        return view(&apos;checkout.cancel&apos;, [
            &apos;order&apos; =&gt; $order-&gt;load(&apos;groupBuy&apos;),
        ]);
    }
}
```

### Step 3：路由設定

```php
// routes/web.php
use App\Http\Controllers\CheckoutController;

Route::middleware([&apos;auth&apos;, &apos;verified&apos;])-&gt;group(function () {
    // 訂單
    Route::get(&apos;/orders&apos;, [OrderController::class, &apos;index&apos;])-&gt;name(&apos;orders.index&apos;);
    Route::get(&apos;/orders/{order}&apos;, [OrderController::class, &apos;show&apos;])-&gt;name(&apos;orders.show&apos;);

    // Checkout
    Route::get(&apos;/checkout/{order}&apos;, [CheckoutController::class, &apos;create&apos;])-&gt;name(&apos;checkout.create&apos;);
    Route::get(&apos;/checkout/{order}/success&apos;, [CheckoutController::class, &apos;success&apos;])-&gt;name(&apos;checkout.success&apos;);
    Route::get(&apos;/checkout/{order}/cancel&apos;, [CheckoutController::class, &apos;cancel&apos;])-&gt;name(&apos;checkout.cancel&apos;);
});
```

### Step 4：訂單頁面 View

```html
{{-- resources/views/orders/show.blade.php --}}
&lt;div class=&quot;max-w-2xl mx-auto py-8&quot;&gt;
  &lt;h1 class=&quot;text-2xl font-bold mb-4&quot;&gt;訂單 #{{ $order-&gt;id }}&lt;/h1&gt;

  &lt;div class=&quot;bg-white rounded-lg shadow p-6 mb-6&quot;&gt;
    &lt;div class=&quot;flex justify-between items-center mb-4&quot;&gt;
      &lt;span class=&quot;text-gray-600&quot;&gt;團購&lt;/span&gt;
      &lt;span&gt;{{ $order-&gt;groupBuy-&gt;title }}&lt;/span&gt;
    &lt;/div&gt;

    &lt;div class=&quot;flex justify-between items-center mb-4&quot;&gt;
      &lt;span class=&quot;text-gray-600&quot;&gt;狀態&lt;/span&gt;
      &lt;span
        class=&quot;px-2 py-1 rounded text-sm bg-{{ $order-&gt;status-&gt;color() }}-100 text-{{ $order-&gt;status-&gt;color() }}-800&quot;
      &gt;
        {{ $order-&gt;status-&gt;label() }}
      &lt;/span&gt;
    &lt;/div&gt;

    &lt;hr class=&quot;my-4&quot; /&gt;

    @foreach ($order-&gt;items as $item)
    &lt;div class=&quot;flex justify-between items-center py-2&quot;&gt;
      &lt;span&gt;{{ $item-&gt;product_name }} x {{ $item-&gt;quantity }}&lt;/span&gt;
      &lt;span&gt;NT$ {{ number_format($item-&gt;subtotal) }}&lt;/span&gt;
    &lt;/div&gt;
    @endforeach

    &lt;hr class=&quot;my-4&quot; /&gt;

    &lt;div class=&quot;flex justify-between items-center font-bold text-lg&quot;&gt;
      &lt;span&gt;合計&lt;/span&gt;
      &lt;span&gt;NT$ {{ number_format($order-&gt;total) }}&lt;/span&gt;
    &lt;/div&gt;
  &lt;/div&gt;

  @if ($order-&gt;canPay())
  &lt;a
    href=&quot;{{ route(&apos;checkout.create&apos;, $order) }}&quot;
    class=&quot;block w-full text-center bg-indigo-600 text-white py-3 rounded-lg hover:bg-indigo-700 transition&quot;
  &gt;
    前往付款
  &lt;/a&gt;
  @endif

  @if ($order-&gt;isPaid())
  &lt;div class=&quot;text-center text-green-600 font-medium&quot;&gt;
    已於 {{ $order-&gt;paid_at-&gt;format(&apos;Y/m/d H:i&apos;) }} 完成付款
  &lt;/div&gt;
  @endif
&lt;/div&gt;
```

### Step 5：用 Stripe 測試模式跑完整流程

```bash
# 終端機 1：啟動 Laravel
php artisan serve

# 終端機 2：啟動 Stripe CLI 轉發 webhook
stripe listen --forward-to localhost:8000/stripe/webhook

# 終端機 3：用 Tinker 模擬成團
php artisan tinker
```

```php
&gt;&gt;&gt; $groupBuy = GroupBuy::first();
&gt;&gt;&gt; app(GroupBuyService::class)-&gt;confirm($groupBuy);
&gt;&gt;&gt; Order::where(&apos;group_buy_id&apos;, $groupBuy-&gt;id)-&gt;count();
// 應該等於跟團人數
```

然後用瀏覽器登入任一跟團者帳號，進入訂單頁面，點「前往付款」。你會被導到 Stripe 的付款頁面——輸入測試卡號 `4242 4242 4242 4242`，填任意到期日和 CVC，按下付款。

付款成功後：

1. 瀏覽器被導回 success 頁面
2. Stripe CLI 顯示 `checkout.session.completed` 事件被轉發
3. 訂單狀態從 `pending` 變成 `paid`

```php
# 在 Tinker 裡確認
&gt;&gt;&gt; Order::first()-&gt;fresh()-&gt;status
// App\Enums\OrderStatus::Paid
```

### 處理「某個人付款失敗」

團購場景的特殊挑戰：10 個人跟團，9 個付款成功，1 個付款失敗。怎麼辦？

幾種策略：

```php
// 策略一：給期限，逾期自動取消
// 可以用 Laravel Scheduler（第十二章會教）
// 每小時檢查一次，超過 48 小時沒付款的訂單標記為 failed

// 策略二：允許部分付款完成，照常出貨
// 適合數量彈性的團購

// 策略三：所有人都付款才出貨
// 適合需要精確數量的團購
```

揪好買採用策略一——給 48 小時的付款期限，逾期自動標記為失敗，並釋放名額。具體的排程實作會在第十二章搭配 Scheduler 來做。

## 小結：讓 Stripe 處理金流，你專注在產品

這一章我們走過了金流整合的完整流程：

- **PCI DSS 合規**——不要自己存信用卡號，讓 Stripe 處理
- **Laravel Cashier**——`composer require laravel/cashier`，加 `Billable` trait，搞定
- **Stripe 測試模式**——測試卡號 + Stripe CLI，完全在本地測試金流
- **訂單設計**——Order + OrderItem，用 Enum 管理狀態，用 DB Transaction 確保一致性
- **Checkout Session**——建立 Session → 導向 Stripe → 使用者付款 → 回到你的網站
- **Webhook**——Stripe 打你的伺服器通知付款結果，Cashier 驗證簽名確保安全
- **狀態機**——明確定義合法的狀態轉換，避免不合法的操作

最關鍵的心法是：**不要在 success URL 的 callback 裡更新訂單狀態**。唯一可信的付款確認來源是 webhook。這不是 Laravel 的規矩，而是所有金流整合的通用原則——不管你用的是 Stripe、ECPay 還是任何其他支付服務。

下一章我們要處理成團後的一連串後續動作——寄確認信、推播通知、更新統計。如果這些全部塞在同一個 HTTP request 裡，使用者要等十秒才看到回應。[**Queue 與 Event**](/blog/laravel-guide-queues-events-notifications/) 讓你把這些耗時任務丟到背景去跑，使用者按下按鈕的瞬間就看到回應。</content:encoded><media:content url="https://bobochen.dev/_astro/cover.C4xkk54g.webp" medium="image"/><category>PHP</category><category>Laravel</category><category>Stripe</category><category>Cashier</category><category>Payment</category><category>E-commerce</category><enclosure url="https://bobochen.dev/_astro/cover.C4xkk54g.webp" length="0" type="image/png"/></item><item><title>跟團與成團邏輯：用 Laravel Session 打造從「+1」到「成團確認」</title><link>https://bobochen.dev/blog/laravel-guide-group-buy-logic-session/</link><guid isPermaLink="true">https://bobochen.dev/blog/laravel-guide-group-buy-logic-session/</guid><description>用 Laravel 12 的 Session、Cache 與 Livewire 打造團購「+1 跟團」到「成團確認」的完整流程：從跟團驗證、最低人數成團判斷，到用 DB Transaction 與 lockForUpdate 解決同時搶團的 race condition，並用定時任務處理截止團購。</description><pubDate>Tue, 22 Apr 2025 00:00:00 GMT</pubDate><content:encoded>團購平台的核心，說穿了就是兩個字：+1。有人開團、有人跟團、湊到最低人數就成團。聽起來簡單，但背後的工程考量比你想像的多：

- 使用者還沒登入就想跟團怎麼辦？
- 同時一百個人按下「+1」會不會超賣？
- 截止時間到了但人數不夠要怎麼處理？
- 成團瞬間要同時通知所有跟團者、建立訂單、鎖定庫存，這些動作的順序和原子性都不能出錯。

這一章是「揪好買」的心臟。我們要用 Laravel 的 Session 機制處理使用者的暫存狀態，用 Cache 加速熱門團購的讀取，用 Livewire 做即時的跟團人數更新，然後把「成團條件判斷」這個最關鍵的業務邏輯寫得清楚明白。技術選型不是重點——Session driver 用 database 還是 Redis、Cache 用 file 還是 Memcached，這些換一行設定就能改。真正難的是業務邏輯本身：什麼時候該鎖定、什麼時候該釋放、edge case 怎麼處理。

老實說，框架教學最容易迴避的就是業務邏輯。因為每個專案不一樣，沒有標準答案。但我認為這才是你真正需要練習的部分——把模糊的需求轉化成明確的程式碼。這一章，我們就來面對它。

## 跟團前先懂 Session：HTTP 是無狀態的

HTTP 本身是無狀態的（stateless）。每一次請求對 server 來說都是全新的陌生人——server 不知道五秒前那個看了團購列表的人，跟現在要按 +1 的人是不是同一個。這就好像你每次走進便利商店，店員都不認得你。

Web 應用程式怎麼解決這個問題？**Session**。

Session 的運作原理其實很簡單：

1. 使用者第一次造訪網站時，server 產生一個隨機 ID（Session ID）
2. 這個 ID 透過 Cookie 送回瀏覽器
3. 往後每次請求，瀏覽器自動帶上這個 Cookie
4. Server 收到 Cookie，用 Session ID 查找對應的資料
5. 資料存在 server 端（檔案、資料庫、Redis），不在瀏覽器裡

所以 Session 就是 server 端的暫存記憶體——你放什麼進去，下一次請求都還在。

### 跨框架對照

| 概念         | Laravel                     | Express (Node.js)         | Django (Python)                  |
| ------------ | --------------------------- | ------------------------- | -------------------------------- |
| Session 套件 | 內建                        | `express-session`         | 內建                             |
| 儲存位置設定 | `SESSION_DRIVER` in `.env`  | `store` option            | `SESSION_ENGINE`                 |
| 讀取         | `session(&apos;key&apos;)`            | `req.session.key`         | `request.session[&apos;key&apos;]`         |
| 寫入         | `session([&apos;key&apos; =&gt; &apos;val&apos;])` | `req.session.key = &apos;val&apos;` | `request.session[&apos;key&apos;] = &apos;val&apos;` |
| Flash data   | `session()-&gt;flash()`        | `req.flash()` (需套件)    | `messages` framework             |

如果你從 Express 轉過來，Laravel 的 Session 概念一模一樣，只是 Express 需要你自己裝 `express-session` 然後選 store（memory、redis、mongo），而 Laravel 全部幫你包好了——改一行 `.env` 就切換儲存方式。

## Laravel Session 機制：Driver 選擇與設定

打開 `.env`，找到 `SESSION_DRIVER`：

```bash
SESSION_DRIVER=database
```

### Driver 一覽

| Driver     | 說明                               | 適合場景                    |
| ---------- | ---------------------------------- | --------------------------- |
| `file`     | 存在 `storage/framework/sessions/` | 單機開發、小型站台          |
| `database` | 存在 `sessions` 資料表             | Laravel 12 預設，通用且可靠 |
| `redis`    | 存在 Redis                         | 高流量正式環境，速度最快    |
| `cookie`   | 加密後存在 Cookie                  | 輕量，但有 4KB 大小限制     |
| `array`    | 存在記憶體（request 結束就消失）   | 測試用                      |

Laravel 12 新建專案預設用 `database`。執行 `php artisan migrate` 時會自動建好 `sessions` 資料表——你不用額外做任何事。

&gt; **生產環境建議：** 如果你的流量大，Session driver 換成 `redis` 是最直接的升級。改一行 `SESSION_DRIVER=redis`，前提是你裝了 Redis server。開發階段用 `database` 就好，不要過早優化。

### Session 基本操作

```php
// ── 寫入 ──
session([&apos;cart_quantity&apos; =&gt; 3]);
// 或者
session()-&gt;put(&apos;cart_quantity&apos;, 3);

// ── 讀取 ──
$qty = session(&apos;cart_quantity&apos;);          // 取得值
$qty = session(&apos;cart_quantity&apos;, 0);       // 預設值（key 不存在時回傳 0）
$qty = session()-&gt;get(&apos;cart_quantity&apos;, 0); // 等效寫法

// ── 檢查 ──
if (session()-&gt;has(&apos;cart_quantity&apos;)) {
    // key 存在且不為 null
}
if (session()-&gt;exists(&apos;cart_quantity&apos;)) {
    // key 存在（可能為 null）
}

// ── 刪除 ──
session()-&gt;forget(&apos;cart_quantity&apos;);       // 刪除單一 key
session()-&gt;forget([&apos;cart_quantity&apos;, &apos;selected_group&apos;]); // 刪除多個
session()-&gt;flush();                       // 清空整個 session

// ── 所有資料 ──
$all = session()-&gt;all();
```

### Flash Data：一次性訊息

Flash data 是只在「下一次請求」有效的 session 資料，用完即消。最典型的用途是操作成功/失敗的通知訊息：

```php
// Controller 裡
session()-&gt;flash(&apos;success&apos;, &apos;成功加入團購！&apos;);
return redirect(&quot;/group-buys/{$groupBuy-&gt;id}&quot;);

// 或用 redirect 的語法糖
return redirect(&quot;/group-buys/{$groupBuy-&gt;id}&quot;)
    -&gt;with(&apos;success&apos;, &apos;成功加入團購！&apos;);
```

在 Blade 裡顯示：

```blade
@if(session(&apos;success&apos;))
&lt;div class=&quot;bg-green-100 text-green-700 px-4 py-3 rounded mb-4&quot;&gt;{{ session(&apos;success&apos;) }}&lt;/div&gt;
@endif
@if(session(&apos;error&apos;))
&lt;div class=&quot;bg-red-100 text-red-700 px-4 py-3 rounded mb-4&quot;&gt;{{ session(&apos;error&apos;) }}&lt;/div&gt;
@endif
```

重新整理頁面後，這些訊息就消失了——因為 flash data 只活一次。

## 跟團邏輯設計：選數量、加入、取消

好，技術工具介紹完了。現在進入這一章的核心——跟團邏輯。

### 需求拆解

使用者在團購詳情頁看到一個「+1 跟團」按鈕。點下去之前，他要先選擇數量（跟幾份）。點下去之後：

1. **驗證**——團購還在開團嗎？還沒滿嗎？這個人已經跟過了嗎？
2. **寫入**——把這個人加到 `group_buy_user` 中間表
3. **回饋**——頁面顯示「成功加入！」，參與人數即時更新

取消跟團的邏輯相反：從中間表 detach，人數減少。

### 驗證規則

在寫程式碼之前，先把規則列清楚。這是業務邏輯最重要的步驟——先用人話寫出所有條件，再翻譯成程式碼：

1. 團購 `status` 必須是 `open`
2. 團購 `deadline` 還沒過
3. 使用者必須已登入
4. 使用者不能重複加入同一個團購
5. 如果有 `max_participants`，目前人數不能超過上限
6. 數量必須是正整數

### Livewire Component：JoinGroupBuy

```bash
php artisan make:livewire JoinGroupBuy
```

```php
&lt;?php

// app/Livewire/JoinGroupBuy.php
namespace App\Livewire;

use App\Models\GroupBuy;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;

class JoinGroupBuy extends Component
{
    public GroupBuy $groupBuy;
    public int $quantity = 1;
    public bool $hasJoined = false;
    public int $currentQuantity = 0;

    public function mount(GroupBuy $groupBuy): void
    {
        $this-&gt;groupBuy = $groupBuy;

        if (Auth::check()) {
            $existing = $groupBuy-&gt;participants()
                -&gt;where(&apos;user_id&apos;, Auth::id())
                -&gt;first();

            if ($existing) {
                $this-&gt;hasJoined = true;
                $this-&gt;currentQuantity = $existing-&gt;pivot-&gt;quantity;
            }
        }
    }

    public function join(): void
    {
        // 1. 必須登入
        if (! Auth::check()) {
            $this-&gt;redirect(route(&apos;login&apos;));
            return;
        }

        // 2. 驗證數量
        $this-&gt;validate([
            &apos;quantity&apos; =&gt; &apos;required|integer|min:1|max:10&apos;,
        ]);

        // 3. 團購還在開團嗎？
        if ($this-&gt;groupBuy-&gt;status !== &apos;open&apos;) {
            session()-&gt;flash(&apos;error&apos;, &apos;這個團購已經不接受跟團了。&apos;);
            return;
        }

        // 4. 還沒截止嗎？
        if ($this-&gt;groupBuy-&gt;deadline-&gt;isPast()) {
            session()-&gt;flash(&apos;error&apos;, &apos;這個團購已經截止了。&apos;);
            return;
        }

        // 5. 沒有重複加入？
        if ($this-&gt;hasJoined) {
            session()-&gt;flash(&apos;error&apos;, &apos;你已經跟過這個團了。&apos;);
            return;
        }

        // 6. 還有名額嗎？
        if ($this-&gt;groupBuy-&gt;max_participants) {
            $currentCount = $this-&gt;groupBuy-&gt;participants()-&gt;count();
            if ($currentCount &gt;= $this-&gt;groupBuy-&gt;max_participants) {
                session()-&gt;flash(&apos;error&apos;, &apos;這個團已經滿了。&apos;);
                return;
            }
        }

        // 7. 一切驗證通過，加入團購
        $this-&gt;groupBuy-&gt;participants()-&gt;attach(Auth::id(), [
            &apos;quantity&apos; =&gt; $this-&gt;quantity,
        ]);

        $this-&gt;hasJoined = true;
        $this-&gt;currentQuantity = $this-&gt;quantity;

        // 通知其他 Livewire component 更新
        $this-&gt;dispatch(&apos;participant-updated&apos;);

        session()-&gt;flash(&apos;success&apos;, &quot;成功跟團！你選了 {$this-&gt;quantity} 份。&quot;);
    }

    public function leave(): void
    {
        if (! $this-&gt;hasJoined) {
            return;
        }

        // 已成團的不能退出
        if ($this-&gt;groupBuy-&gt;status !== &apos;open&apos;) {
            session()-&gt;flash(&apos;error&apos;, &apos;團購已成團，無法退出。&apos;);
            return;
        }

        $this-&gt;groupBuy-&gt;participants()-&gt;detach(Auth::id());

        $this-&gt;hasJoined = false;
        $this-&gt;currentQuantity = 0;

        $this-&gt;dispatch(&apos;participant-updated&apos;);

        session()-&gt;flash(&apos;success&apos;, &apos;你已退出這個團購。&apos;);
    }

    public function render()
    {
        return view(&apos;livewire.join-group-buy&apos;);
    }
}
```

對應的 Blade 模板：

```blade
&lt;!-- resources/views/livewire/join-group-buy.blade.php --&gt;
&lt;div&gt;
  @if(session(&apos;success&apos;))
  &lt;div class=&quot;bg-green-100 text-green-700 px-4 py-3 rounded mb-4&quot;&gt;{{ session(&apos;success&apos;) }}&lt;/div&gt;
  @endif
  @if(session(&apos;error&apos;))
  &lt;div class=&quot;bg-red-100 text-red-700 px-4 py-3 rounded mb-4&quot;&gt;{{ session(&apos;error&apos;) }}&lt;/div&gt;
  @endif
  @if($hasJoined)
  &lt;div class=&quot;bg-indigo-50 border border-indigo-200 rounded-lg p-4&quot;&gt;
    &lt;p class=&quot;text-indigo-700 font-medium&quot;&gt;你已跟團 {{ $currentQuantity }} 份&lt;/p&gt;
    @if($groupBuy-&gt;status === &apos;open&apos;)
    &lt;button
      wire:click=&quot;leave&quot;
      wire:confirm=&quot;確定要退出嗎？&quot;
      class=&quot;mt-2 text-sm text-red-600 hover:text-red-800&quot;
    &gt;
      退出團購
    &lt;/button&gt;
    @endif
  &lt;/div&gt;
  @else
  @if($groupBuy-&gt;status === &apos;open&apos; &amp;&amp; ! $groupBuy-&gt;deadline-&gt;isPast())
  &lt;div class=&quot;flex items-center gap-3&quot;&gt;
    &lt;label class=&quot;text-sm text-gray-600&quot;&gt;數量&lt;/label&gt;
    &lt;select wire:model=&quot;quantity&quot; class=&quot;rounded border px-3 py-2&quot;&gt;
      @for($i = 1; $i &lt;= 10; $i++)
      &lt;option value=&quot;{{ $i }}&quot;&gt;{{ $i }} 份&lt;/option&gt;
      @endfor
    &lt;/select&gt;
    &lt;button
      wire:click=&quot;join&quot;
      class=&quot;bg-emerald-600 text-white px-6 py-2 rounded-lg hover:bg-emerald-700 transition&quot;
    &gt;
      &lt;span wire:loading.remove wire:target=&quot;join&quot;&gt;+1 跟團&lt;/span&gt;
      &lt;span wire:loading wire:target=&quot;join&quot;&gt;處理中...&lt;/span&gt;
    &lt;/button&gt;
  &lt;/div&gt;
  @else
  &lt;p class=&quot;text-gray-500&quot;&gt;此團購已截止或不再接受跟團&lt;/p&gt;
  @endif
  @endif
&lt;/div&gt;
```

### 為什麼用 `attach()` / `detach()`？

回顧[第四章的多對多關聯](/blog/laravel-guide-eloquent-orm-models/)——`group_buy_user` 是 pivot table，`attach()` 新增一筆關聯，`detach()` 移除。這比自己手寫 `DB::table(&apos;group_buy_user&apos;)-&gt;insert(...)` 乾淨很多，而且 Eloquent 會自動幫你維護 timestamps。

## 成團條件判斷：最低人數與截止時間

跟團邏輯處理的是個人行為——一個人加入、一個人退出。**成團邏輯**處理的是整個團的狀態轉換。

### 狀態轉換圖

```
                ┌──────────────┐
                │    open      │
                │  （開團中）    │
                └──────┬───────┘
                       │
            ┌──────────┴──────────┐
            │                     │
    人數 &gt;= 最低門檻         截止時間到了
    (可提前成團)           但人數不夠
            │                     │
            ▼                     ▼
    ┌──────────────┐     ┌──────────────┐
    │  confirmed   │     │  cancelled   │
    │  （已成團）    │     │  （已取消）    │
    └──────────────┘     └──────────────┘
```

規則很明確：

1. **成團**：參與人數 &gt;= `min_participants`（不管截止時間到了沒，都可以成團）
2. **取消**：截止時間到了，但參與人數 &lt; `min_participants`
3. **開團中**：還沒截止，人數也還不夠

不過「人數一夠就提前成團」是我做的產品選擇，不是唯一正解，這裡先講清楚兩件事：

- **提前鎖定有代價**：有些團購反而希望跑滿整個截止時間，盡量蒐集人數去衝更低的折扣級距（湊到 50 人比 20 人便宜）。你一達標就 confirm，等於把後面那批人擋在門外，揪團規模反而變小。要不要提前成團，看你的折扣是不是階梯式的——如果是，可能該等截止才結算。
- **`checkAndConfirm()` 要有人叫它**：這個方法不會自己跑。下面你會看到它靠 `join()` 事件或每 5 分鐘的 scheduler 觸發，但等一下的 `JoinGroupBuy::join()` 其實只有 attach 完 dispatch UI 事件、**沒有呼叫 `checkAndConfirm()`**。所以實務上的成團時間點是「下一個人 +1」或「scheduler 下一輪」，最慘可能拖好幾分鐘——明明第 5 個人已經進來了，狀態卻還掛在 open。要避免這種「達標但遲遲沒成團」的尷尬，記得在 `join()` 的 attach 之後補一行 `$this-&gt;groupBuy-&gt;checkAndConfirm()`，讓達標當下就結算。

### GroupBuy Model 方法：`checkAndConfirm()`

```php
// app/Models/GroupBuy.php

use Illuminate\Support\Facades\DB;

/**
 * 檢查並更新成團狀態
 * 回傳是否發生狀態變更
 */
public function checkAndConfirm(): bool
{
    // 只有 open 狀態才需要檢查
    if ($this-&gt;status !== &apos;open&apos;) {
        return false;
    }

    $participantCount = $this-&gt;participants()-&gt;count();

    // 情況一：人數夠了 → 成團
    if ($participantCount &gt;= $this-&gt;min_participants) {
        return $this-&gt;confirmGroup();
    }

    // 情況二：截止了但人數不夠 → 取消
    if ($this-&gt;deadline-&gt;isPast() &amp;&amp; $participantCount &lt; $this-&gt;min_participants) {
        return $this-&gt;cancelGroup();
    }

    // 情況三：還在進行中
    return false;
}

private function confirmGroup(): bool
{
    return DB::transaction(function () {
        // 用悲觀鎖鎖住這筆團購，避免 race condition
        $groupBuy = GroupBuy::lockForUpdate()-&gt;find($this-&gt;id);

        // 再次確認狀態（double-check locking）
        if ($groupBuy-&gt;status !== &apos;open&apos;) {
            return false;
        }

        $groupBuy-&gt;update([&apos;status&apos; =&gt; &apos;confirmed&apos;]);

        // TODO: 第十章會加入——通知所有參與者、建立訂單
        // event(new GroupBuyConfirmed($groupBuy));

        return true;
    });
}

private function cancelGroup(): bool
{
    return DB::transaction(function () {
        $groupBuy = GroupBuy::lockForUpdate()-&gt;find($this-&gt;id);

        if ($groupBuy-&gt;status !== &apos;open&apos;) {
            return false;
        }

        $groupBuy-&gt;update([&apos;status&apos; =&gt; &apos;cancelled&apos;]);

        // TODO: 通知所有參與者團購取消
        // event(new GroupBuyCancelled($groupBuy));

        return true;
    });
}
```

成團後的通知與訂單建立（`GroupBuyConfirmed` 事件、Queue 處理）將在[第十章：Queue、Event 與通知](/blog/laravel-guide-queues-events-notifications/)詳細介紹。

### 為什麼需要 `DB::transaction()` 和 `lockForUpdate()`？

想像這個場景：團購需要 5 人成團，目前已經有 4 個人。第五個人和第六個人幾乎同時按下 +1。

**沒有鎖的情況：**

```
使用者 A 讀取人數 → 4 人
使用者 B 讀取人數 → 4 人
使用者 A 加入 → 5 人 → 觸發成團 ✅
使用者 B 加入 → 6 人 → 又觸發成團？或超過上限？❌
```

**有鎖的情況：**

```
使用者 A 取得鎖 → 讀取 4 人 → 加入 → 5 人 → 成團 → 釋放鎖
使用者 B 等待鎖 → 取得鎖 → 讀取 5 人 → 已成團，不再處理 → 釋放鎖
```

`lockForUpdate()` 是資料庫的悲觀鎖（`SELECT ... FOR UPDATE`），確保同一時間只有一個 process 能修改這筆資料。搭配 `DB::transaction()` 保證整組操作的原子性——要嘛全部成功，要嘛全部回滾。

&gt; **悲觀鎖不是萬靈丹，講幾句它的代價。** `FOR UPDATE` 在低併發下很好用，但併發一上來，搶不到鎖的 request 會排隊乾等，連線一個個卡住，連線池滿了就開始噴 error；兩筆交易互相等對方的鎖還會 deadlock。它也綁 DB 引擎——MySQL InnoDB / Postgres 行為正常，SQLite 根本不是那樣鎖，你本機測過了上線可能兩種行為。所以前面說「高流量改用 Redis」跟這裡說「用悲觀鎖」其實有點打架：真高流量時，悲觀鎖本身可能就是瓶頸。
&gt;
&gt; 想換做法的話：樂觀鎖（加個 `version` 欄位，update 時帶條件，撞到就重試）、單一原子 `UPDATE ... WHERE status = &apos;open&apos;`（靠 DB 自己的行鎖，不用顯式 `FOR UPDATE`）、或把成團檢查丟進 queue 用單一 worker 序列化處理，都比硬鎖更耐操。
&gt;
&gt; 還有一句更重要的：**真正怕超賣的是「名額/庫存」那一層**，那裡才該下重手防併發。成團狀態從 open 轉 confirmed 只會發生一次、併發量通常很低，這裡用悲觀鎖是「夠用」而非「最佳」——學概念剛好，別把它當成所有 race condition 的標準答案。

### Scheduled Command：定時檢查過期團購

有些團購不會被人手動觸發成團檢查——可能截止時間到了但最後一個人早就加入了，沒有新的 +1 來觸發 `checkAndConfirm()`。所以我們需要一個定時任務來掃描過期的團購。

```bash
php artisan make:command CheckExpiredGroupBuys
```

```php
&lt;?php

// app/Console/Commands/CheckExpiredGroupBuys.php
namespace App\Console\Commands;

use App\Models\GroupBuy;
use Illuminate\Console\Command;

class CheckExpiredGroupBuys extends Command
{
    protected $signature = &apos;group-buys:check-expired&apos;;
    protected $description = &apos;檢查已截止的團購，成團或取消&apos;;

    public function handle(): int
    {
        $expiredGroups = GroupBuy::where(&apos;status&apos;, &apos;open&apos;)
            -&gt;where(&apos;deadline&apos;, &apos;&lt;=&apos;, now())
            -&gt;get();

        $confirmed = 0;
        $cancelled = 0;

        foreach ($expiredGroups as $groupBuy) {
            if ($groupBuy-&gt;checkAndConfirm()) {
                if ($groupBuy-&gt;fresh()-&gt;status === &apos;confirmed&apos;) {
                    $confirmed++;
                } else {
                    $cancelled++;
                }
            }
        }

        $this-&gt;info(&quot;處理完成：{$confirmed} 個成團、{$cancelled} 個取消&quot;);

        return self::SUCCESS;
    }
}
```

在 `routes/console.php`（Laravel 12 的排程檔案）裡註冊：

```php
// routes/console.php
use Illuminate\Support\Facades\Schedule;

Schedule::command(&apos;group-buys:check-expired&apos;)-&gt;everyFiveMinutes();
```

每五分鐘跑一次，把所有已截止的團購該成團的成團、該取消的取消。生產環境記得啟動 scheduler：

```bash
# crontab -e，加入這一行
* * * * * cd /path-to-project &amp;&amp; php artisan schedule:run &gt;&gt; /dev/null 2&gt;&amp;1
```

## Cache Facade：快取熱門資料

團購列表頁可能有幾十個團購，每一個都要 `$groupBuy-&gt;participants()-&gt;count()` 去查 pivot table——如果首頁流量大，這些 COUNT 查詢會成為瓶頸。Cache（快取）可以大幅減少資料庫壓力。

### Cache 基本操作

```php
use Illuminate\Support\Facades\Cache;

// ── 存入 ──
Cache::put(&apos;key&apos;, &apos;value&apos;, now()-&gt;addMinutes(30));  // 30 分鐘後過期

// ── 讀取 ──
$value = Cache::get(&apos;key&apos;);               // 不存在回傳 null
$value = Cache::get(&apos;key&apos;, &apos;default&apos;);    // 不存在回傳 default

// ── remember：不存在就執行 closure 並快取 ──
$count = Cache::remember(&apos;group_buy_42_count&apos;, now()-&gt;addMinutes(5), function () {
    return GroupBuy::find(42)-&gt;participants()-&gt;count();
});
// 第一次：跑 DB 查詢，結果存入快取
// 之後五分鐘內：直接從快取拿，不碰 DB

// ── 刪除 ──
Cache::forget(&apos;group_buy_42_count&apos;);

// ── 永久快取 ──
Cache::forever(&apos;site_settings&apos;, $settings);
```

### 在揪好買中快取跟團人數

```php
// app/Models/GroupBuy.php

public function cachedParticipantCount(): int
{
    return Cache::remember(
        &quot;group_buy_{$this-&gt;id}_participant_count&quot;,
        now()-&gt;addMinutes(5),
        fn () =&gt; $this-&gt;participants()-&gt;count()
    );
}
```

在 JoinGroupBuy component 的 `join()` 和 `leave()` 方法裡，加入 cache 失效：

```php
// 加入或退出後，清除快取
Cache::forget(&quot;group_buy_{$this-&gt;groupBuy-&gt;id}_participant_count&quot;);
```

### Cache Driver 選擇

```bash
# .env
CACHE_STORE=database   # Laravel 12 預設
```

| Driver     | 特點            | 適合場景       |
| ---------- | --------------- | -------------- |
| `file`     | 零設定          | 開發、小站     |
| `database` | 可靠，預設      | 一般用途       |
| `redis`    | 最快，支援 tags | 高流量正式環境 |
| `array`    | 不持久化        | 測試           |

跟 Session driver 一樣的故事——開發用 `database`，流量大了再換 `redis`。程式碼完全不用改。

## 登入前後狀態合併策略

這是很容易被忽略的 UX 問題：使用者還沒登入就開始瀏覽團購，甚至把某個團購加到「想跟」清單。登入之後，這些行為不應該消失——要把 session 裡的暫存資料合併到資料庫裡。

### 場景

1. 訪客 A 瀏覽團購 #42，把它加入「收藏清單」（存在 session）
2. 訪客 A 按下「+1 跟團」→ 被導向登入頁
3. A 登入成功 → 回到團購 #42 → 收藏清單裡應該還有 #42

### 實作：合併 Session 資料

Laravel 在使用者登入時會觸發 `Login` event。我們可以監聽這個 event，在登入後把 session 資料合併到資料庫：

```php
&lt;?php

// app/Listeners/MergeSessionDataAfterLogin.php
namespace App\Listeners;

use Illuminate\Auth\Events\Login;

class MergeSessionDataAfterLogin
{
    public function handle(Login $event): void
    {
        $user = $event-&gt;user;

        // 合併收藏清單
        $sessionFavorites = session()-&gt;pull(&apos;guest_favorites&apos;, []);

        if (! empty($sessionFavorites)) {
            // 把 session 裡的收藏寫到 user 的資料庫記錄
            foreach ($sessionFavorites as $groupBuyId) {
                $user-&gt;favorites()-&gt;syncWithoutDetaching([$groupBuyId]);
            }
        }

        // 如果訪客有「想跟團」的意圖，導向那個團購頁
        $pendingJoin = session()-&gt;pull(&apos;pending_join_group_buy&apos;);

        if ($pendingJoin) {
            session()-&gt;flash(&apos;info&apos;, &apos;你可以繼續完成跟團了！&apos;);
            // Livewire 或 Controller 可以根據這個 flash 做導向
        }
    }
}
```

在 `EventServiceProvider` 或 `AppServiceProvider` 裡註冊：

```php
// app/Providers/AppServiceProvider.php
use App\Listeners\MergeSessionDataAfterLogin;
use Illuminate\Auth\Events\Login;
use Illuminate\Support\Facades\Event;

public function boot(): void
{
    Event::listen(Login::class, MergeSessionDataAfterLogin::class);
}
```

而在 JoinGroupBuy component 的 `join()` 方法裡，如果使用者未登入，先記住意圖：

```php
public function join(): void
{
    if (! Auth::check()) {
        // 記住使用者想跟哪個團
        session()-&gt;put(&apos;pending_join_group_buy&apos;, $this-&gt;groupBuy-&gt;id);
        $this-&gt;redirect(route(&apos;login&apos;));
        return;
    }

    // ...後續驗證和加入邏輯
}
```

登入成功後，使用者會被導回原頁面，看到一條 flash 訊息提醒他繼續完成跟團。

&gt; **Session 的妙用：** 很多人以為 session 只是「登入狀態」，其實它是通用的暫存工具。訪客瀏覽行為、購物車、表單草稿、多步驟流程的中間狀態——都可以用 session 暫存，登入後再合併到資料庫。

## Livewire 即時更新跟團人數

團購詳情頁除了 JoinGroupBuy component，還需要顯示「目前幾人跟團」。這個數字要在有人加入/退出時即時更新——前面的 JoinGroupBuy 會 dispatch `participant-updated` 事件，我們可以做一個 component 來監聽它。

### ParticipantCounter Component

```php
&lt;?php

// app/Livewire/ParticipantCounter.php
namespace App\Livewire;

use App\Models\GroupBuy;
use Livewire\Attributes\On;
use Livewire\Component;

class ParticipantCounter extends Component
{
    public GroupBuy $groupBuy;
    public int $count = 0;
    public int $totalQuantity = 0;

    public function mount(GroupBuy $groupBuy): void
    {
        $this-&gt;groupBuy = $groupBuy;
        $this-&gt;refreshCount();
    }

    #[On(&apos;participant-updated&apos;)]
    public function refreshCount(): void
    {
        $this-&gt;count = $this-&gt;groupBuy-&gt;participants()-&gt;count();
        $this-&gt;totalQuantity = (int) $this-&gt;groupBuy
            -&gt;participants()
            -&gt;sum(&apos;group_buy_user.quantity&apos;);
    }

    public function render()
    {
        $progress = $this-&gt;groupBuy-&gt;min_participants &gt; 0
            ? min(100, round(($this-&gt;count / $this-&gt;groupBuy-&gt;min_participants) * 100))
            : 0;

        return view(&apos;livewire.participant-counter&apos;, [
            &apos;progress&apos; =&gt; $progress,
        ]);
    }
}
```

```blade
&lt;!-- resources/views/livewire/participant-counter.blade.php --&gt;
&lt;div wire:poll.30s=&quot;refreshCount&quot;&gt;
  &lt;div class=&quot;flex items-baseline gap-2 mb-2&quot;&gt;
    &lt;span class=&quot;text-3xl font-bold text-indigo-600&quot;&gt;{{ $count }}&lt;/span&gt;
    &lt;span class=&quot;text-gray-500&quot;&gt;/ {{ $groupBuy-&gt;min_participants }} 人&lt;/span&gt;
    @if($groupBuy-&gt;max_participants)
    &lt;span class=&quot;text-gray-400 text-sm&quot;&gt;（上限 {{ $groupBuy-&gt;max_participants }} 人）&lt;/span&gt;
    @endif
  &lt;/div&gt;

  {{-- 進度條 --}}
  &lt;div class=&quot;w-full bg-gray-200 rounded-full h-3 mb-2&quot;&gt;
    &lt;div
      class=&quot;bg-emerald-500 h-3 rounded-full transition-all duration-500&quot;
      style=&quot;width: {{ $progress }}%&quot;
    &gt;&lt;/div&gt;
  &lt;/div&gt;

  &lt;p class=&quot;text-sm text-gray-500&quot;&gt;
    共 {{ $totalQuantity }} 份 @if($progress &gt;= 100)
    &lt;span class=&quot;text-emerald-600 font-medium&quot;&gt;已達成團門檻！&lt;/span&gt;
    @else
    ，還差 {{ $groupBuy-&gt;min_participants - $count }} 人成團
    @endif
  &lt;/p&gt;
&lt;/div&gt;
```

### 關鍵設計

- **`#[On(&apos;participant-updated&apos;)]`**：PHP 8 Attribute 語法，告訴 Livewire「當收到 `participant-updated` 事件時，執行這個方法」。JoinGroupBuy 在加入/退出後 dispatch 這個事件，ParticipantCounter 就會即時更新——同一頁面上的跨 component 通訊。
- **`wire:poll.30s`**：每 30 秒自動跟 server 同步一次。這是為了處理「別人在其他瀏覽器跟團」的情況——你不會收到 Livewire 事件，但 poll 會定時拉最新數字。
- **進度條**：視覺化呈現成團進度，用百分比計算寬度。CSS `transition-all` 讓寬度變化有動畫。

&gt; **wire:poll 的代價：** 每 30 秒一次 AJAX 請求。如果頁面上有很多使用者同時瀏覽，這會產生不少請求。正式環境如果流量真的很大，可以考慮用 Laravel Echo + WebSocket 做真正的即時推播。但對揪好買的規模來說，30 秒 poll 完全夠用。

## 倒數計時：團購截止提醒

截止時間的倒數計時是純前端的事——每秒更新一次，不需要打 server。用 Alpine.js 就對了。

```blade
&lt;!-- 嵌入 group-buy show 頁面 --&gt;
&lt;div
  x-data=&quot;countdown(&apos;{{ $groupBuy-&gt;deadline-&gt;toIso8601String() }}&apos;)&quot;
  x-init=&quot;start()&quot;
  class=&quot;text-center&quot;
&gt;
  &lt;template x-if=&quot;!expired&quot;&gt;
    &lt;div&gt;
      &lt;p class=&quot;text-sm text-gray-500 mb-1&quot;&gt;距離截止還有&lt;/p&gt;
      &lt;div class=&quot;flex justify-center gap-3&quot;&gt;
        &lt;div class=&quot;text-center&quot;&gt;
          &lt;span class=&quot;text-2xl font-bold text-indigo-600&quot; x-text=&quot;days&quot;&gt;&lt;/span&gt;
          &lt;p class=&quot;text-xs text-gray-400&quot;&gt;天&lt;/p&gt;
        &lt;/div&gt;
        &lt;span class=&quot;text-2xl text-gray-300&quot;&gt;:&lt;/span&gt;
        &lt;div class=&quot;text-center&quot;&gt;
          &lt;span class=&quot;text-2xl font-bold text-indigo-600&quot; x-text=&quot;hours&quot;&gt;&lt;/span&gt;
          &lt;p class=&quot;text-xs text-gray-400&quot;&gt;時&lt;/p&gt;
        &lt;/div&gt;
        &lt;span class=&quot;text-2xl text-gray-300&quot;&gt;:&lt;/span&gt;
        &lt;div class=&quot;text-center&quot;&gt;
          &lt;span class=&quot;text-2xl font-bold text-indigo-600&quot; x-text=&quot;minutes&quot;&gt;&lt;/span&gt;
          &lt;p class=&quot;text-xs text-gray-400&quot;&gt;分&lt;/p&gt;
        &lt;/div&gt;
        &lt;span class=&quot;text-2xl text-gray-300&quot;&gt;:&lt;/span&gt;
        &lt;div class=&quot;text-center&quot;&gt;
          &lt;span class=&quot;text-2xl font-bold text-indigo-600&quot; x-text=&quot;seconds&quot;&gt;&lt;/span&gt;
          &lt;p class=&quot;text-xs text-gray-400&quot;&gt;秒&lt;/p&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/template&gt;

  &lt;template x-if=&quot;expired&quot;&gt;
    &lt;p class=&quot;text-red-600 font-medium&quot;&gt;此團購已截止&lt;/p&gt;
  &lt;/template&gt;
&lt;/div&gt;
```

Alpine.js 的 countdown 函式放在全域 JS 裡：

```javascript
// resources/js/countdown.js
document.addEventListener(&apos;alpine:init&apos;, () =&gt; {
  Alpine.data(&apos;countdown&apos;, (deadline) =&gt; ({
    days: &apos;00&apos;,
    hours: &apos;00&apos;,
    minutes: &apos;00&apos;,
    seconds: &apos;00&apos;,
    expired: false,
    interval: null,

    start() {
      this.update();
      this.interval = setInterval(() =&gt; this.update(), 1000);
    },

    update() {
      const now = new Date().getTime();
      const target = new Date(deadline).getTime();
      const diff = target - now;

      if (diff &lt;= 0) {
        this.expired = true;
        clearInterval(this.interval);
        // 截止了，重新整理頁面讓 server 更新狀態
        setTimeout(() =&gt; window.location.reload(), 2000);
        return;
      }

      this.days = String(Math.floor(diff / (1000 * 60 * 60 * 24))).padStart(2, &apos;0&apos;);
      this.hours = String(Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))).padStart(
        2,
        &apos;0&apos;
      );
      this.minutes = String(Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))).padStart(2, &apos;0&apos;);
      this.seconds = String(Math.floor((diff % (1000 * 60)) / 1000)).padStart(2, &apos;0&apos;);
    },

    destroy() {
      clearInterval(this.interval);
    },
  }));
});
```

在 `resources/js/app.js` 引入：

```javascript
import &apos;./countdown.js&apos;;
```

### 為什麼倒數計時用 Alpine.js 而不是 Livewire？

因為倒數計時需要**每秒**更新。如果用 `wire:poll.1s`，每秒都要跑一趟 AJAX 到 server——這完全沒必要，截止時間又不會變。純前端的 `setInterval` 搭配 Alpine.js，零 server 負擔，體驗也更流暢。

這是 Livewire + Alpine.js 分工的經典案例：

- **需要 server 資料（人數、狀態）** → Livewire
- **純 UI 計算（倒數、動畫）** → Alpine.js

## 實作：揪好買的跟團完整流程

把前面所有元件串起來，做一個完整的團購詳情頁。

### 路由

```php
// routes/web.php
Route::get(&apos;/group-buys/{groupBuy}&apos;, function (GroupBuy $groupBuy) {
    return view(&apos;group-buys.show&apos;, compact(&apos;groupBuy&apos;));
})-&gt;name(&apos;group-buys.show&apos;);
```

### 團購詳情頁

```blade
&lt;!-- resources/views/group-buys/show.blade.php --&gt;
&lt;x-layouts.app :title=&quot;$groupBuy-&gt;title&quot;&gt;
  &lt;div class=&quot;max-w-3xl mx-auto&quot;&gt;
    {{-- 標題區 --}}
    &lt;div class=&quot;mb-6&quot;&gt;
      &lt;div class=&quot;flex items-center gap-3 mb-2&quot;&gt;
        &lt;h1 class=&quot;text-2xl font-bold&quot;&gt;{{ $groupBuy-&gt;title }}&lt;/h1&gt;
        &lt;span
          class=&quot;text-sm px-2 py-1 rounded
                    {{ $groupBuy-&gt;status === &apos;open&apos; ? &apos;bg-green-100 text-green-700&apos; : &apos;&apos; }}
                    {{ $groupBuy-&gt;status === &apos;confirmed&apos; ? &apos;bg-blue-100 text-blue-700&apos; : &apos;&apos; }}
                    {{ $groupBuy-&gt;status === &apos;cancelled&apos; ? &apos;bg-red-100 text-red-700&apos; : &apos;&apos; }}
                &quot;
        &gt;
          {{ match($groupBuy-&gt;status) { &apos;open&apos; =&gt; &apos;開團中&apos;, &apos;confirmed&apos; =&gt; &apos;已成團&apos;, &apos;cancelled&apos; =&gt;
          &apos;已取消&apos;, default =&gt; $groupBuy-&gt;status, } }}
        &lt;/span&gt;
      &lt;/div&gt;
      &lt;p class=&quot;text-gray-500&quot;&gt;
        開團者：{{ $groupBuy-&gt;organizer-&gt;name }} ・{{ $groupBuy-&gt;created_at-&gt;diffForHumans() }}
      &lt;/p&gt;
    &lt;/div&gt;

    {{-- 主要內容 --}}
    &lt;div class=&quot;grid md:grid-cols-3 gap-6&quot;&gt;
      {{-- 左欄：商品資訊 --}}
      &lt;div class=&quot;md:col-span-2 space-y-6&quot;&gt;
        @if($groupBuy-&gt;image)
        &lt;img
          src=&quot;{{ Storage::url($groupBuy-&gt;image) }}&quot;
          alt=&quot;{{ $groupBuy-&gt;product_name }}&quot;
          class=&quot;w-full rounded-xl&quot;
        /&gt;
        @endif

        &lt;div class=&quot;prose max-w-none&quot;&gt;
          &lt;h2&gt;{{ $groupBuy-&gt;product_name }}&lt;/h2&gt;
          &lt;p&gt;{{ $groupBuy-&gt;description }}&lt;/p&gt;
        &lt;/div&gt;

        &lt;div class=&quot;bg-gray-50 rounded-lg p-4&quot;&gt;
          &lt;p class=&quot;text-2xl font-bold text-indigo-600&quot;&gt;
            ${{ number_format($groupBuy-&gt;price_per_unit / 100) }}
            &lt;span class=&quot;text-sm text-gray-500 font-normal&quot;&gt;/ 份&lt;/span&gt;
          &lt;/p&gt;
        &lt;/div&gt;

        {{-- 參與者列表 --}}
        &lt;div&gt;
          &lt;h3 class=&quot;font-bold text-lg mb-3&quot;&gt;跟團者&lt;/h3&gt;
          @forelse($groupBuy-&gt;participants as $participant)
          &lt;div class=&quot;flex items-center justify-between py-2 border-b last:border-0&quot;&gt;
            &lt;span&gt;{{ $participant-&gt;name }}&lt;/span&gt;
            &lt;span class=&quot;text-sm text-gray-500&quot;&gt;
              {{ $participant-&gt;pivot-&gt;quantity }} 份 @if($participant-&gt;pivot-&gt;note) ・{{
              $participant-&gt;pivot-&gt;note }} @endif
            &lt;/span&gt;
          &lt;/div&gt;
          @empty
          &lt;p class=&quot;text-gray-400&quot;&gt;還沒有人跟團，成為第一個吧！&lt;/p&gt;
          @endforelse
        &lt;/div&gt;
      &lt;/div&gt;

      {{-- 右欄：跟團操作 --}}
      &lt;div class=&quot;space-y-6&quot;&gt;
        {{-- 倒數計時 --}}
        @if($groupBuy-&gt;status === &apos;open&apos;)
        @include(&apos;partials.countdown&apos;, [&apos;deadline&apos; =&gt; $groupBuy-&gt;deadline])
        @endif
        {{-- 跟團人數 --}}
        &lt;div class=&quot;bg-white rounded-xl border p-5&quot;&gt;
          &lt;h3 class=&quot;font-medium text-gray-700 mb-3&quot;&gt;跟團進度&lt;/h3&gt;
          &lt;livewire:participant-counter :group-buy=&quot;$groupBuy&quot; /&gt;
        &lt;/div&gt;

        {{-- 跟團按鈕 --}}
        &lt;div class=&quot;bg-white rounded-xl border p-5&quot;&gt;
          &lt;livewire:join-group-buy :group-buy=&quot;$groupBuy&quot; /&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/x-layouts.app&gt;
```

### 流程測試

```bash
# 重建資料庫和測試資料
php artisan migrate:fresh --seed

# 啟動開發 server
php artisan serve &amp;
npm run dev &amp;
```

開兩個瀏覽器（或一個開無痕模式），用不同帳號登入：

1. **瀏覽器 A**：進入某個團購頁，看到「0 / 5 人」
2. **瀏覽器 A**：選 2 份，按 +1 跟團 → 成功，顯示「1 / 5 人」
3. **瀏覽器 B**：登入另一個帳號，進入同一個團購
4. **瀏覽器 B**：看到「1 / 5 人」（wire:poll 會同步）
5. **瀏覽器 B**：按 +1 跟團 → 成功，顯示「2 / 5 人」
6. **瀏覽器 A**：等 30 秒或重新整理 → 看到「2 / 5 人」

再測邊界情況：

- 嘗試重複加入 → 看到「你已經跟過這個團了」
- 在團購截止後按 +1 → 看到「這個團購已經截止了」
- 退出團購 → 人數減少，按鈕恢復成「+1 跟團」

### Tinker 測試成團

```bash
php artisan tinker

&gt;&gt;&gt; $gb = GroupBuy::where(&apos;status&apos;, &apos;open&apos;)-&gt;first()
&gt;&gt;&gt; $gb-&gt;min_participants
# 假設是 3

&gt;&gt;&gt; $gb-&gt;participants()-&gt;count()
# 假設已經有 3 人

&gt;&gt;&gt; $gb-&gt;checkAndConfirm()
# true

&gt;&gt;&gt; $gb-&gt;fresh()-&gt;status
# &quot;confirmed&quot;
```

也可以手動跑定時任務：

```bash
php artisan group-buys:check-expired
# 處理完成：1 個成團、2 個取消
```

## 小結：業務邏輯才是最難的部分

回顧這一章的技術點：

- **Session**——server 端的暫存記憶體，`put()` / `get()` / `flash()` 三板斧
- **Cache**——`Cache::remember()` 快取熱門查詢結果，減少 DB 壓力
- **DB Transaction + lockForUpdate()**——保護成團判斷的原子性，避免 race condition
- **Scheduled Command**——定時檢查過期團購，`php artisan schedule:run`
- **Livewire Events**——`$this-&gt;dispatch()` + `#[On()]` 做跨 component 通訊
- **Alpine.js**——純前端倒數計時，不打 server
- **Login Event Listener**——登入後合併 session 資料

但老實說，這些技術點都不是這一章最難的部分。最難的是**業務邏輯的設計**：

- 什麼時候成團？什麼時候取消？什麼時候保持開放？
- 誰能加入？加入的條件是什麼？退出的限制呢？
- Race condition 怎麼處理？狀態轉換的原子性怎麼保證？
- 登入前後的使用者體驗怎麼銜接？

框架給你工具，但不會告訴你「最低成團人數應該設在哪裡檢查」或「截止時間到了但人數不夠要不要寬限十分鐘」。這些是業務決策，只有跟產品經理（或者自己兼任產品經理的你）討論清楚之後，才能轉化成明確的 `if-else`。

我的建議：**先用人話把所有規則列出來，再寫程式碼**。本章的驗證清單就是這個做法——六條規則寫清楚了，程式碼幾乎是自動翻譯出來的。最怕的是邊寫邊想、邊改邊加，最後程式碼變成一堆互相矛盾的條件分支，連自己都看不懂。

下一章，我們要面對成團之後最關鍵的問題——收錢。訂單怎麼建立？Stripe 怎麼串接？[Laravel Cashier](/blog/laravel-guide-orders-stripe-cashier/) 能幫我們做到什麼？團購的收款跟一般電商不一樣——不是「買了就付」，而是「成團了才收」。這個時序差異會影響整個金流架構的設計。</content:encoded><media:content url="https://bobochen.dev/_astro/cover.BQrJGPPN.webp" medium="image"/><category>PHP</category><category>Laravel</category><category>Session</category><category>Cache</category><category>團購</category><category>Business Logic</category><enclosure url="https://bobochen.dev/_astro/cover.BQrJGPPN.webp" length="0" type="image/png"/></item><item><title>表單驗證與檔案上傳：讓使用者好好提交資料</title><link>https://bobochen.dev/blog/laravel-guide-validation-file-upload/</link><guid isPermaLink="true">https://bobochen.dev/blog/laravel-guide-validation-file-upload/</guid><description>學會用 Laravel 12 打造安全可靠的表單驗證與檔案上傳：從 inline validate、Form Request 抽離驗證邏輯、自訂 Rule class，到 Storage facade 統一管理本地與 S3 檔案、Intervention Image 產生 WebP 縮圖。以「揪好買」團購平台的開團表單為例，完整實作驗證規則、中文化錯誤訊息與圖片上傳流程。</description><pubDate>Tue, 15 Apr 2025 00:00:00 GMT</pubDate><content:encoded>使用者輸入的資料，永遠不能信任。這不是偏執，是血淚教訓。前端驗證擋得了手滑的一般使用者，擋不了：

- 開 DevTools 直接改掉 HTML 的人
- 用 `curl` 繞過瀏覽器直打你 API 的人
- 自動化攻擊腳本

後端驗證是最後一道防線，也是唯一可靠的防線。

Laravel 的驗證系統大概是我用過最舒服的——內建超過 90 條驗證規則，從 `required`、`email`、`max` 到 `exists:users,id` 這種直接查資料庫的都有，而且錯誤訊息自動對應、自動回填表單，開發體驗好到讓你不想偷懶跳過驗證。

除了文字資料，表單常常還要處理檔案上傳——大頭照、商品圖片、附件。Laravel 的 Storage facade 把檔案操作抽象化，不管你底層用的是本地硬碟、Amazon S3 還是其他雲端儲存，程式碼寫法都一樣。搭配 Form Request 把驗證邏輯從 Controller 搬出去，你的 Controller 就能保持精簡，每個 method 不超過十行。

在「揪好買」團購平台裡，開團主建立團購時要填寫商品名稱、描述、最低成團人數、截止時間，還要上傳商品圖片。這一章我們會完整實作這張「開團」表單——從驗證規則、錯誤處理、圖片上傳到縮圖生成，每一步都用 Laravel 最佳實踐來做。

## 為什麼需要後端驗證：前端擋不住的事

你可能會想：「我前端已經用 JavaScript 做了驗證，使用者填錯會即時提示，應該夠了吧？」讓我示範一下為什麼不夠。

假設你的「開團」表單前端限制了標題必須填寫、價格必須是正數。但攻擊者完全可以繞過瀏覽器，直接用 curl 送請求：

```bash
# 繞過前端，直接打後端 API
curl -X POST http://localhost:8000/group-buys \
  -H &quot;Content-Type: application/json&quot; \
  -d &apos;{&quot;title&quot;: &quot;&quot;, &quot;price_per_unit&quot;: -100, &quot;deadline&quot;: &quot;1999-01-01&quot;}&apos;
```

前端驗證完全沒機會攔截這個請求。標題是空的、價格是負數、截止時間在 25 年前——如果後端照單全收，你的資料庫就被塞了一筆垃圾資料。

後端驗證攔的就是這種「形狀壞掉」的資料：空標題、負數價格、過去的截止日。前端的 `required` 和 `minLength` 一個 `curl` 就繞過了，所以這道把關只能放在後端。

所以原則很簡單：**前端驗證是為了使用者體驗（即時反饋），後端驗證是為了把關資料的正確性。兩者缺一不可，但如果只能選一個，一定是後端。**

不過我要先講清楚一件事，免得你誤會：validation 不是萬靈丹。`required|string|max:2000` 這種規則管的是「資料長什麼樣、符不符合業務規則」，它不會幫你擋 SQL injection，也不會擋 stored XSS。那是另外兩層的事：

- **防 SQL injection** 靠的是 Eloquent / Query Builder 的參數化綁定。只要你不去手動拼 raw SQL 字串，注入這條路本來就被堵住了，跟你有沒有寫 validation 無關。
- **防 XSS** 靠的是輸出端——Blade 的 `{{ }}` 預設會做 HTML escape。所以 description 就算真的被塞了 `&lt;script&gt;`，渲染時也只會變成純文字顯示，不會被瀏覽器執行（除非你用 `{!! !!}` 自己關掉轉義）。

換句話說，validation、ORM 參數化、Blade 轉義是三道不同的防線，各管各的，缺一不可。別把「我有寫 validation」當成「我的網站很安全」——這章先把資料正確性顧好，注入和 XSS 後面會另外談。

## Validation Rules：Laravel 內建的驗證武器庫

Laravel 最簡單的驗證方式是直接在 Controller 裡呼叫 `$request-&gt;validate()`：

```php
public function store(Request $request)
{
    $validated = $request-&gt;validate([
        &apos;title&apos;            =&gt; &apos;required|string|min:3|max:100&apos;,
        &apos;description&apos;      =&gt; &apos;required|string|max:2000&apos;,
        &apos;product_name&apos;     =&gt; &apos;required|string|max:100&apos;,
        &apos;price_per_unit&apos;   =&gt; &apos;required|integer|min:1&apos;,
        &apos;min_participants&apos; =&gt; &apos;required|integer|min:2|max:500&apos;,
        &apos;max_participants&apos; =&gt; &apos;nullable|integer|gte:min_participants&apos;,
        &apos;deadline&apos;         =&gt; &apos;required|date|after:today&apos;,
        &apos;image&apos;            =&gt; &apos;nullable|image|mimes:jpg,png,webp|max:2048&apos;,
    ]);

    // 如果走到這裡，表示驗證全部通過
    // $validated 只包含驗證過的資料（安全）
    GroupBuy::create($validated);
}
```

驗證失敗時，Laravel 自動做三件事：

1. 把使用者**重新導向回前一頁**
2. 把**所有錯誤訊息**存進 Session（`$errors`）
3. 把**舊的輸入值**存進 Session（`old()`）——方便回填表單

你不需要手寫任何一行重導向或錯誤處理程式碼。

### 常用驗證規則速查表

Laravel 內建超過 90 條規則，這裡列出最常用的：

| 規則                  | 說明                                | 範例                              |
| --------------------- | ----------------------------------- | --------------------------------- |
| `required`            | 必填                                | `&apos;title&apos; =&gt; &apos;required&apos;`           |
| `string`              | 必須是字串                          | `&apos;name&apos; =&gt; &apos;string&apos;`              |
| `integer`             | 必須是整數                          | `&apos;price&apos; =&gt; &apos;integer&apos;`            |
| `numeric`             | 必須是數字（含小數）                | `&apos;weight&apos; =&gt; &apos;numeric&apos;`           |
| `min:N`               | 最小值/最小長度                     | `&apos;price&apos; =&gt; &apos;min:1&apos;`              |
| `max:N`               | 最大值/最大長度                     | `&apos;title&apos; =&gt; &apos;max:100&apos;`            |
| `email`               | 必須是合法 Email                    | `&apos;email&apos; =&gt; &apos;email&apos;`              |
| `unique:table,column` | 資料庫裡不能重複                    | `&apos;email&apos; =&gt; &apos;unique:users,email&apos;` |
| `exists:table,column` | 資料庫裡必須存在                    | `&apos;user_id&apos; =&gt; &apos;exists:users,id&apos;`  |
| `in:a,b,c`            | 只能是指定值之一                    | `&apos;status&apos; =&gt; &apos;in:open,closed&apos;`    |
| `date`                | 必須是合法日期                      | `&apos;deadline&apos; =&gt; &apos;date&apos;`            |
| `after:date`          | 必須在某日期之後                    | `&apos;deadline&apos; =&gt; &apos;after:today&apos;`     |
| `before:date`         | 必須在某日期之前                    | `&apos;end&apos; =&gt; &apos;before:2027-01-01&apos;`    |
| `gte:field`           | 大於等於另一個欄位                  | `&apos;max&apos; =&gt; &apos;gte:min&apos;`              |
| `image`               | 必須是圖片                          | `&apos;photo&apos; =&gt; &apos;image&apos;`              |
| `mimes:jpg,png`       | 限制檔案類型                        | `&apos;doc&apos; =&gt; &apos;mimes:pdf,docx&apos;`       |
| `max:N`（檔案）       | 最大檔案大小（KB）                  | `&apos;image&apos; =&gt; &apos;max:2048&apos;`           |
| `nullable`            | 允許 null                           | `&apos;bio&apos; =&gt; &apos;nullable\|string&apos;`     |
| `confirmed`           | 必須有 `_confirmation` 欄位且值相同 | `&apos;password&apos; =&gt; &apos;confirmed&apos;`       |
| `url`                 | 必須是合法 URL                      | `&apos;website&apos; =&gt; &apos;url&apos;`              |
| `boolean`             | 必須是布林值                        | `&apos;agree&apos; =&gt; &apos;boolean&apos;`            |

規則之間用 `|` 連接，也可以用陣列寫法：

```php
// 管線寫法（簡短規則適用）
&apos;title&apos; =&gt; &apos;required|string|max:100&apos;,

// 陣列寫法（規則多或含特殊字元時更清楚）
&apos;title&apos; =&gt; [&apos;required&apos;, &apos;string&apos;, &apos;max:100&apos;],
```

### 跨框架對照

如果你從其他框架來，這裡做個對照：

| 概念     | Laravel                        | Express (Node.js)                | Django (Python)            |
| -------- | ------------------------------ | -------------------------------- | -------------------------- |
| 驗證方式 | `$request-&gt;validate()`         | joi / zod / express-validator    | Django Forms / Serializers |
| 規則定義 | 字串 `&apos;required\|email&apos;`       | Schema 物件 `z.string().email()` | Field 類別 `EmailField()`  |
| 錯誤回傳 | 自動 redirect + session        | 手動回傳 JSON                    | 手動或 form.errors         |
| 表單回填 | `old(&apos;field&apos;)` 自動可用        | 自己處理                         | `form.initial`             |
| 檔案驗證 | 同一套規則 `&apos;image\|max:2048&apos;` | multer + 自己驗證                | `FileField` + validators   |

Laravel 的優勢在於**一站式**：驗證規則、錯誤訊息、表單回填、檔案處理全部整合在一起。不像 Express 要自己組裝 multer + zod + 錯誤處理 middleware。

## Form Request：把驗證邏輯從 Controller 搬出去

直接在 Controller 裡寫 `$request-&gt;validate()` 很方便，但當表單欄位一多、規則一複雜，Controller 就會變得臃腫。Laravel 的解法是 **Form Request**——一個專門負責驗證的類別。

```bash
php artisan make:request StoreGroupBuyRequest
```

這會產生 `app/Http/Requests/StoreGroupBuyRequest.php`：

```php
&lt;?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreGroupBuyRequest extends FormRequest
{
    /**
     * 這個使用者有權限送出這個表單嗎？
     */
    public function authorize(): bool
    {
        // 搭配[第六章的 Policy](/blog/laravel-guide-auth-breeze-authorization)
        return $this-&gt;user()-&gt;can(&apos;create&apos;, \App\Models\GroupBuy::class);
    }

    /**
     * 驗證規則
     */
    public function rules(): array
    {
        return [
            &apos;title&apos;            =&gt; [&apos;required&apos;, &apos;string&apos;, &apos;min:3&apos;, &apos;max:100&apos;],
            &apos;description&apos;      =&gt; [&apos;required&apos;, &apos;string&apos;, &apos;max:2000&apos;],
            &apos;product_name&apos;     =&gt; [&apos;required&apos;, &apos;string&apos;, &apos;max:100&apos;],
            &apos;price_per_unit&apos;   =&gt; [&apos;required&apos;, &apos;integer&apos;, &apos;min:1&apos;],
            &apos;min_participants&apos; =&gt; [&apos;required&apos;, &apos;integer&apos;, &apos;min:2&apos;, &apos;max:500&apos;],
            &apos;max_participants&apos; =&gt; [&apos;nullable&apos;, &apos;integer&apos;, &apos;gte:min_participants&apos;],
            &apos;deadline&apos;         =&gt; [&apos;required&apos;, &apos;date&apos;, &apos;after:today&apos;],
            &apos;image&apos;            =&gt; [&apos;nullable&apos;, &apos;image&apos;, &apos;mimes:jpg,png,webp&apos;, &apos;max:2048&apos;],
        ];
    }

    /**
     * 自訂錯誤訊息（可選）
     */
    public function messages(): array
    {
        return [
            &apos;title.required&apos;          =&gt; &apos;團購標題是必填的&apos;,
            &apos;title.min&apos;               =&gt; &apos;標題至少要 :min 個字&apos;,
            &apos;price_per_unit.min&apos;      =&gt; &apos;單價必須大於 0&apos;,
            &apos;min_participants.min&apos;    =&gt; &apos;最低成團人數至少要 :min 人&apos;,
            &apos;max_participants.gte&apos;    =&gt; &apos;人數上限不能小於最低成團人數&apos;,
            &apos;deadline.after&apos;          =&gt; &apos;截止時間必須是未來的日期&apos;,
            &apos;image.max&apos;              =&gt; &apos;圖片大小不能超過 2MB&apos;,
        ];
    }
}
```

三個關鍵方法：

1. **`authorize()`**——權限檢查。回傳 `false` 就丟 403 Forbidden。搭配[第六章〈認證與授權〉](/blog/laravel-guide-auth-breeze-authorization)的 Policy 使用。
2. **`rules()`**——驗證規則。跟 `$request-&gt;validate()` 的寫法一模一樣。
3. **`messages()`**——自訂錯誤訊息。可選，不寫就用 Laravel 預設的英文訊息。

### 在 Controller 裡使用 Form Request

重點來了。把 `Request` 改成 `StoreGroupBuyRequest`，Laravel 自動在進入 Controller 之前完成驗證：

```php
use App\Http\Requests\StoreGroupBuyRequest;

class GroupBuyController extends Controller
{
    public function store(StoreGroupBuyRequest $request)
    {
        // 走到這裡，表示驗證和授權都通過了
        $validated = $request-&gt;validated();

        $groupBuy = GroupBuy::create([
            ...$validated,
            &apos;user_id&apos; =&gt; $request-&gt;user()-&gt;id,
        ]);

        return redirect(&quot;/group-buys/{$groupBuy-&gt;id}&quot;)
            -&gt;with(&apos;success&apos;, &apos;團購建立成功！&apos;);
    }
}
```

注意 `store()` 方法只有六行——沒有 if/else、沒有錯誤處理、沒有 redirect 回表單。所有髒活都由 Form Request 在 Controller 方法被呼叫**之前**處理完了。

這就是 Form Request 的威力：**Controller 只負責業務邏輯，驗證邏輯完全分離。**

&gt; **什麼時候用 Form Request、什麼時候用 inline validate？** 如果驗證規則不超過三條，inline `$request-&gt;validate()` 就好，別過度工程化。超過三條、或是同一組規則在多處使用（store 和 update 共用）、或是需要自訂 `authorize()`，就抽成 Form Request。

## 自訂驗證規則：當內建不夠用

Laravel 內建 90 多條規則，但業務邏輯總有特殊需求。例如揪好買有一條規則：**截止時間必須是至少 24 小時後**（避免開團主設一個一小時後就截止的團購，其他人根本來不及跟）。

### 方法一：Closure Rule（內聯）

最快的做法，直接在 rules 陣列裡寫：

```php
&apos;deadline&apos; =&gt; [
    &apos;required&apos;,
    &apos;date&apos;,
    &apos;after:today&apos;,
    function (string $attribute, mixed $value, \Closure $fail) {
        if (now()-&gt;diffInHours($value, absolute: false) &lt; 24) {
            $fail(&apos;截止時間必須是至少 24 小時後&apos;);
        }
    },
],
```

Closure 接收三個參數：欄位名稱、值、`$fail` callback。驗證失敗就呼叫 `$fail()` 並傳入錯誤訊息。

適合一次性的規則。如果同一個規則在多處使用，就該抽成 Rule class。

### 方法二：Rule Class（可重用）

```bash
php artisan make:rule MinHoursFromNow
```

```php
&lt;?php

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class MinHoursFromNow implements ValidationRule
{
    public function __construct(
        private int $hours = 24,
    ) {}

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (now()-&gt;diffInHours($value, absolute: false) &lt; $this-&gt;hours) {
            $fail(&quot;:{$attribute} 必須是至少 {$this-&gt;hours} 小時後&quot;);
        }
    }
}
```

使用：

```php
use App\Rules\MinHoursFromNow;

&apos;deadline&apos; =&gt; [&apos;required&apos;, &apos;date&apos;, new MinHoursFromNow(24)],
```

乾淨、可重用、可測試。如果之後其他表單也需要「至少 N 小時後」的規則，直接 `new MinHoursFromNow(48)` 就好。

## Error Message 中文化

Laravel 預設的錯誤訊息是英文：&quot;The title field is required.&quot;。對揪好買的台灣使用者來說，我們需要中文訊息。

### 設定語言檔

建立 `lang/zh_TW/validation.php`：

```bash
# 先建立目錄
mkdir -p lang/zh_TW
```

```php
&lt;?php
// lang/zh_TW/validation.php

return [
    &apos;required&apos;  =&gt; &apos;:attribute 為必填&apos;,
    &apos;string&apos;    =&gt; &apos;:attribute 必須是字串&apos;,
    &apos;integer&apos;   =&gt; &apos;:attribute 必須是整數&apos;,
    &apos;min&apos;       =&gt; [
        &apos;numeric&apos; =&gt; &apos;:attribute 不能小於 :min&apos;,
        &apos;string&apos;  =&gt; &apos;:attribute 至少要 :min 個字&apos;,
        &apos;file&apos;    =&gt; &apos;:attribute 不能小於 :min KB&apos;,
    ],
    &apos;max&apos;       =&gt; [
        &apos;numeric&apos; =&gt; &apos;:attribute 不能大於 :max&apos;,
        &apos;string&apos;  =&gt; &apos;:attribute 不能超過 :max 個字&apos;,
        &apos;file&apos;    =&gt; &apos;:attribute 不能超過 :max KB&apos;,
    ],
    &apos;email&apos;     =&gt; &apos;:attribute 格式不正確&apos;,
    &apos;unique&apos;    =&gt; &apos;:attribute 已經被使用&apos;,
    &apos;date&apos;      =&gt; &apos;:attribute 必須是有效日期&apos;,
    &apos;after&apos;     =&gt; &apos;:attribute 必須是 :date 之後的日期&apos;,
    &apos;image&apos;     =&gt; &apos;:attribute 必須是圖片&apos;,
    &apos;mimes&apos;     =&gt; &apos;:attribute 只接受 :values 格式&apos;,
    &apos;confirmed&apos; =&gt; &apos;:attribute 與確認欄位不一致&apos;,
    &apos;gte&apos;       =&gt; [
        &apos;numeric&apos; =&gt; &apos;:attribute 必須大於或等於 :value&apos;,
    ],

    // 自訂欄位名稱（把英文欄位名換成中文）
    &apos;attributes&apos; =&gt; [
        &apos;title&apos;            =&gt; &apos;團購標題&apos;,
        &apos;description&apos;      =&gt; &apos;團購說明&apos;,
        &apos;product_name&apos;     =&gt; &apos;商品名稱&apos;,
        &apos;price_per_unit&apos;   =&gt; &apos;單價&apos;,
        &apos;min_participants&apos; =&gt; &apos;最低成團人數&apos;,
        &apos;max_participants&apos; =&gt; &apos;人數上限&apos;,
        &apos;deadline&apos;         =&gt; &apos;截止時間&apos;,
        &apos;image&apos;            =&gt; &apos;商品圖片&apos;,
        &apos;email&apos;            =&gt; &apos;電子信箱&apos;,
        &apos;password&apos;         =&gt; &apos;密碼&apos;,
        &apos;name&apos;             =&gt; &apos;姓名&apos;,
    ],
];
```

然後在 `config/app.php` 設定語系：

```php
&apos;locale&apos; =&gt; &apos;zh_TW&apos;,
```

現在驗證失敗時，使用者看到的是「團購標題 為必填」「截止時間 必須是 today 之後的日期」——比英文友善多了。

### 在 Blade 顯示錯誤訊息

```blade
&lt;div class=&quot;mb-4&quot;&gt;
  &lt;label for=&quot;title&quot; class=&quot;block text-sm font-medium text-gray-700&quot;&gt;團購標題&lt;/label&gt;
  &lt;input
    type=&quot;text&quot;
    name=&quot;title&quot;
    id=&quot;title&quot;
    value=&quot;{{ old(&apos;title&apos;) }}&quot;
    class=&quot;mt-1 w-full rounded-lg border px-4 py-2 @error(&apos;title&apos;) border-red-500 @enderror&quot;
  /&gt;
  @error(&apos;title&apos;)
  &lt;p class=&quot;mt-1 text-sm text-red-600&quot;&gt;{{ $message }}&lt;/p&gt;
  @enderror
&lt;/div&gt;
```

三個關鍵點：

1. **`old(&apos;title&apos;)`**——回填使用者上次輸入的值，驗證失敗後不用重新打字
2. **`@error(&apos;title&apos;)`**——如果 `title` 欄位有錯誤，渲染裡面的內容
3. **`{{ $message }}`**——該欄位的第一條錯誤訊息

`@error` 是 Blade 的語法糖，等價於：

```blade
@if($errors-&gt;has(&apos;title&apos;))
&lt;p&gt;{{ $errors-&gt;first(&apos;title&apos;) }}&lt;/p&gt;
@endif
```

## 檔案上傳：Storage Facade 統一管理

揪好買的開團表單需要上傳商品圖片。Laravel 的檔案處理圍繞一個核心概念：**Storage Facade**。不管你的檔案存在本地硬碟、Amazon S3、Google Cloud Storage 還是 DigitalOcean Spaces，程式碼寫法都一樣——只需要在設定檔切換 driver。

### 儲存空間設定

打開 `config/filesystems.php`：

```php
&apos;disks&apos; =&gt; [
    &apos;local&apos; =&gt; [
        &apos;driver&apos; =&gt; &apos;local&apos;,
        &apos;root&apos; =&gt; storage_path(&apos;app/private&apos;),
    ],

    &apos;public&apos; =&gt; [
        &apos;driver&apos; =&gt; &apos;local&apos;,
        &apos;root&apos; =&gt; storage_path(&apos;app/public&apos;),
        &apos;url&apos; =&gt; env(&apos;APP_URL&apos;) . &apos;/storage&apos;,
        &apos;visibility&apos; =&gt; &apos;public&apos;,
    ],

    &apos;s3&apos; =&gt; [
        &apos;driver&apos; =&gt; &apos;s3&apos;,
        &apos;key&apos; =&gt; env(&apos;AWS_ACCESS_KEY_ID&apos;),
        &apos;secret&apos; =&gt; env(&apos;AWS_SECRET_ACCESS_KEY&apos;),
        &apos;region&apos; =&gt; env(&apos;AWS_DEFAULT_REGION&apos;),
        &apos;bucket&apos; =&gt; env(&apos;AWS_BUCKET&apos;),
    ],
],
```

三種常用 disk：

- **`local`**——存在 `storage/app/private/`，外部無法存取（適合敏感文件）
- **`public`**——存在 `storage/app/public/`，可透過 URL 存取（適合商品圖片）
- **`s3`**——Amazon S3 或相容服務（正式環境推薦）

### 建立 Symbolic Link

`public` disk 的檔案存在 `storage/app/public/` 裡，但使用者透過瀏覽器只能存取 `public/` 目錄。Laravel 用一個 symbolic link 把兩者連起來：

```bash
php artisan storage:link
```

這會建立 `public/storage` → `storage/app/public` 的捷徑。之後 `storage/app/public/images/product.jpg` 就能透過 `http://localhost:8000/storage/images/product.jpg` 存取。

### 上傳檔案的基本操作

```php
use Illuminate\Support\Facades\Storage;

// 儲存上傳的檔案（自動產生唯一檔名）
$path = $request-&gt;file(&apos;image&apos;)-&gt;store(&apos;group-buys&apos;, &apos;public&apos;);
// 回傳類似 &quot;group-buys/abc123def456.jpg&quot;

// 指定檔名
$path = $request-&gt;file(&apos;image&apos;)-&gt;storeAs(
    &apos;group-buys&apos;,
    &quot;gb-{$groupBuy-&gt;id}.jpg&quot;,
    &apos;public&apos;
);

// 取得完整 URL
$url = Storage::disk(&apos;public&apos;)-&gt;url($path);
// &quot;http://localhost:8000/storage/group-buys/abc123def456.jpg&quot;

// 刪除檔案
Storage::disk(&apos;public&apos;)-&gt;delete($path);

// 檢查檔案是否存在
if (Storage::disk(&apos;public&apos;)-&gt;exists($path)) {
    // ...
}
```

### 檔案驗證規則

上傳的檔案也要驗證——不只是檢查格式，更要限制大小，防止使用者傳一個 100MB 的檔案把你的硬碟塞爆：

```php
&apos;image&apos; =&gt; [
    &apos;nullable&apos;,           // 允許不上傳
    &apos;image&apos;,              // 必須是圖片（jpg, png, gif, bmp, svg, webp）
    &apos;mimes:jpg,png,webp&apos;, // 限制為這三種格式
    &apos;max:2048&apos;,           // 最大 2MB（2048 KB）
    &apos;dimensions:min_width=400,min_height=300&apos;,  // 最小尺寸
],
```

&gt; **為什麼限制 mimes？** `image` 規則會接受 GIF、BMP、SVG 等格式。但商品圖片我們只要 JPG、PNG、WebP——這三種壓縮效率好、瀏覽器支援完整。SVG 甚至可能藏 XSS 攻擊。

## 圖片處理與縮圖

使用者上傳的原始圖片可能是 4000x3000 像素、5MB 大小。直接當商品圖顯示太慢了。我們需要產生適合網頁顯示的縮圖。

### 安裝 Intervention Image

```bash
composer require intervention/image
```

[Intervention Image](https://image.intervention.io/) 是 PHP 生態系最主流的圖片處理套件，提供簡潔的 API 來裁剪、縮放、加浮水印等。

### 基本使用

```php
use Intervention\Image\Laravel\Facades\Image;
use Intervention\Image\Encoders\WebpEncoder;

// 從上傳檔案建立 Image 實例
$image = Image::read($request-&gt;file(&apos;image&apos;));

// 等比例縮放：寬度最大 800px，高度自動計算
$image-&gt;scale(width: 800);

// 裁剪成正方形（從中心裁）
$image-&gt;cover(400, 400);

// 轉成 WebP 格式並儲存
$encoded = $image-&gt;encode(new WebpEncoder(quality: 80));

Storage::disk(&apos;public&apos;)-&gt;put(
    &quot;group-buys/{$groupBuy-&gt;id}.webp&quot;,
    $encoded
);
```

### 產生多種尺寸

商品圖片通常需要多種尺寸——列表頁的縮圖、詳情頁的大圖：

```php
private function processImage(UploadedFile $file, int $groupBuyId): string
{
    $image = Image::read($file);
    $basePath = &quot;group-buys/{$groupBuyId}&quot;;

    // 原圖（限制最大寬度 1200px）
    $original = (clone $image)-&gt;scale(width: 1200);
    Storage::disk(&apos;public&apos;)-&gt;put(
        &quot;{$basePath}/original.webp&quot;,
        $original-&gt;encode(new WebpEncoder(quality: 85))
    );

    // 縮圖（400x300，裁切）
    $thumbnail = (clone $image)-&gt;cover(400, 300);
    Storage::disk(&apos;public&apos;)-&gt;put(
        &quot;{$basePath}/thumbnail.webp&quot;,
        $thumbnail-&gt;encode(new WebpEncoder(quality: 75))
    );

    return &quot;{$basePath}/original.webp&quot;;
}
```

&gt; **為什麼用 WebP？** 相同畫質下，WebP 的檔案大小比 JPG 小 25-35%。2026 年所有主流瀏覽器都支援 WebP。除非你有特殊理由，新專案一律用 WebP。

## 實作：揪好買「開團」表單

把所有東西串起來。我們要實作完整的「開團」表單，包含驗證、圖片上傳、錯誤處理。

### Step 1：Form Request

前面已經寫好了 `StoreGroupBuyRequest`，這裡再加上圖片處理的準備方法：

```php
&lt;?php

namespace App\Http\Requests;

use App\Rules\MinHoursFromNow;
use Illuminate\Foundation\Http\FormRequest;

class StoreGroupBuyRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this-&gt;user()-&gt;can(&apos;create&apos;, \App\Models\GroupBuy::class);
    }

    public function rules(): array
    {
        return [
            &apos;title&apos;            =&gt; [&apos;required&apos;, &apos;string&apos;, &apos;min:3&apos;, &apos;max:100&apos;],
            &apos;description&apos;      =&gt; [&apos;required&apos;, &apos;string&apos;, &apos;max:2000&apos;],
            &apos;product_name&apos;     =&gt; [&apos;required&apos;, &apos;string&apos;, &apos;max:100&apos;],
            &apos;price_per_unit&apos;   =&gt; [&apos;required&apos;, &apos;integer&apos;, &apos;min:1&apos;],
            &apos;min_participants&apos; =&gt; [&apos;required&apos;, &apos;integer&apos;, &apos;min:2&apos;, &apos;max:500&apos;],
            &apos;max_participants&apos; =&gt; [&apos;nullable&apos;, &apos;integer&apos;, &apos;gte:min_participants&apos;],
            &apos;deadline&apos;         =&gt; [&apos;required&apos;, &apos;date&apos;, new MinHoursFromNow(24)],
            &apos;image&apos;            =&gt; [&apos;nullable&apos;, &apos;image&apos;, &apos;mimes:jpg,png,webp&apos;, &apos;max:2048&apos;],
        ];
    }

    public function messages(): array
    {
        return [
            &apos;title.required&apos;       =&gt; &apos;團購標題是必填的&apos;,
            &apos;title.min&apos;            =&gt; &apos;標題至少要 :min 個字&apos;,
            &apos;price_per_unit.min&apos;   =&gt; &apos;單價必須大於 0&apos;,
            &apos;min_participants.min&apos; =&gt; &apos;最低成團人數至少要 :min 人&apos;,
            &apos;max_participants.gte&apos; =&gt; &apos;人數上限不能小於最低成團人數&apos;,
            &apos;deadline.required&apos;    =&gt; &apos;請設定截止時間&apos;,
            &apos;image.max&apos;            =&gt; &apos;圖片大小不能超過 2MB&apos;,
            &apos;image.mimes&apos;          =&gt; &apos;圖片只接受 JPG、PNG、WebP 格式&apos;,
        ];
    }
}
```

### Step 2：Controller

```php
&lt;?php

namespace App\Http\Controllers;

use App\Http\Requests\StoreGroupBuyRequest;
use App\Models\GroupBuy;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Encoders\WebpEncoder;
use Intervention\Image\Laravel\Facades\Image;

class GroupBuyController extends Controller
{
    public function create()
    {
        $this-&gt;authorize(&apos;create&apos;, GroupBuy::class);

        return view(&apos;group-buys.create&apos;);
    }

    public function store(StoreGroupBuyRequest $request)
    {
        $validated = $request-&gt;validated();

        // 建立團購（先不含圖片）
        $groupBuy = GroupBuy::create([
            ...$validated,
            &apos;user_id&apos; =&gt; $request-&gt;user()-&gt;id,
            &apos;status&apos;  =&gt; &apos;open&apos;,
        ]);

        // 處理圖片上傳
        if ($request-&gt;hasFile(&apos;image&apos;)) {
            $groupBuy-&gt;update([
                &apos;image_path&apos; =&gt; $this-&gt;processImage($request-&gt;file(&apos;image&apos;), $groupBuy-&gt;id),
            ]);
        }

        return redirect(&quot;/group-buys/{$groupBuy-&gt;id}&quot;)
            -&gt;with(&apos;success&apos;, &apos;團購建立成功！開始分享給朋友吧。&apos;);
    }

    private function processImage($file, int $groupBuyId): string
    {
        $image = Image::read($file);
        $basePath = &quot;group-buys/{$groupBuyId}&quot;;

        // 主圖
        $main = (clone $image)-&gt;scale(width: 1200);
        Storage::disk(&apos;public&apos;)-&gt;put(
            &quot;{$basePath}/main.webp&quot;,
            $main-&gt;encode(new WebpEncoder(quality: 85))
        );

        // 縮圖
        $thumb = (clone $image)-&gt;cover(400, 300);
        Storage::disk(&apos;public&apos;)-&gt;put(
            &quot;{$basePath}/thumb.webp&quot;,
            $thumb-&gt;encode(new WebpEncoder(quality: 75))
        );

        return &quot;{$basePath}/main.webp&quot;;
    }
}
```

Controller 裡的 `store()` 只做三件事：取得驗證過的資料、建立記錄、處理圖片。驗證邏輯全在 `StoreGroupBuyRequest`，圖片處理抽成 `private` method。每個 public method 都很短、意圖明確。

&gt; **一個我必須誠實提醒的代價：這裡的 `processImage()` 是同步跑的。** `Image::read()` 解一張大圖、再 `scale` + `cover` 產兩種尺寸、各自編成 WebP——這是 CPU 密集活，一張手機拍的幾 MB 大圖跑下來，從幾百毫秒到一兩秒都有可能。而這整段是卡在 request 週期裡的：使用者按下「開團」之後，就在那邊乾等到圖片全部處理完才看到回應，同時你的 PHP worker 也被這一個請求佔住，PHP-FPM 的執行時間上限一到還可能直接逾時。流量小、原型階段這樣寫完全沒問題，簡單直接。但只要同時開團的人一多、或圖片普遍很大，正確的做法是先把原檔存好、立刻回應使用者，再把 `processImage` 丟到 Queue/Job 背景產縮圖。判斷點很簡單：當你開始看到上傳變慢或偶發逾時，就是該改成非同步的時候。

### Step 3：Blade 表單

`resources/views/group-buys/create.blade.php`：

```blade
&lt;x-layouts.app title=&quot;開新團購&quot;&gt;
  &lt;div class=&quot;max-w-2xl mx-auto&quot;&gt;
    &lt;h1 class=&quot;text-2xl font-bold mb-6&quot;&gt;開新團購&lt;/h1&gt;

    {{-- 全域成功訊息 --}} @if(session(&apos;success&apos;))
    &lt;div class=&quot;bg-green-100 text-green-700 px-4 py-3 rounded-lg mb-6&quot;&gt;
      {{ session(&apos;success&apos;) }}
    &lt;/div&gt;
    @endif

    &lt;form method=&quot;POST&quot; action=&quot;/group-buys&quot; enctype=&quot;multipart/form-data&quot; class=&quot;space-y-6&quot;&gt;
      @csrf {{-- 團購標題 --}}
      &lt;div&gt;
        &lt;label for=&quot;title&quot; class=&quot;block text-sm font-medium text-gray-700&quot;&gt;
          團購標題 &lt;span class=&quot;text-red-500&quot;&gt;*&lt;/span&gt;
        &lt;/label&gt;
        &lt;input
          type=&quot;text&quot;
          name=&quot;title&quot;
          id=&quot;title&quot;
          value=&quot;{{ old(&apos;title&apos;) }}&quot;
          placeholder=&quot;例：阿里山高山茶 春茶團購&quot;
          class=&quot;mt-1 w-full rounded-lg border px-4 py-2 @error(&apos;title&apos;) border-red-500 @enderror&quot;
        /&gt;
        @error(&apos;title&apos;)
        &lt;p class=&quot;mt-1 text-sm text-red-600&quot;&gt;{{ $message }}&lt;/p&gt;
        @enderror
      &lt;/div&gt;

      {{-- 商品名稱 --}}
      &lt;div&gt;
        &lt;label for=&quot;product_name&quot; class=&quot;block text-sm font-medium text-gray-700&quot;&gt;
          商品名稱 &lt;span class=&quot;text-red-500&quot;&gt;*&lt;/span&gt;
        &lt;/label&gt;
        &lt;input
          type=&quot;text&quot;
          name=&quot;product_name&quot;
          id=&quot;product_name&quot;
          value=&quot;{{ old(&apos;product_name&apos;) }}&quot;
          placeholder=&quot;例：阿里山高山烏龍茶 150g&quot;
          class=&quot;mt-1 w-full rounded-lg border px-4 py-2 @error(&apos;product_name&apos;) border-red-500 @enderror&quot;
        /&gt;
        @error(&apos;product_name&apos;)
        &lt;p class=&quot;mt-1 text-sm text-red-600&quot;&gt;{{ $message }}&lt;/p&gt;
        @enderror
      &lt;/div&gt;

      {{-- 團購說明 --}}
      &lt;div&gt;
        &lt;label for=&quot;description&quot; class=&quot;block text-sm font-medium text-gray-700&quot;&gt;
          團購說明 &lt;span class=&quot;text-red-500&quot;&gt;*&lt;/span&gt;
        &lt;/label&gt;
        {{-- 注意：old(&apos;description&apos;) 頂格寫、&lt;/textarea 拆行，是為了避免 textarea 渲染出多餘的前後空白，這是刻意的排版而非格式錯誤 --}}
        &lt;textarea
          name=&quot;description&quot;
          id=&quot;description&quot;
          rows=&quot;4&quot;
          placeholder=&quot;詳細描述商品內容、規格、取貨方式...&quot;
          class=&quot;mt-1 w-full rounded-lg border px-4 py-2 @error(&apos;description&apos;) border-red-500 @enderror&quot;
        &gt;
{{ old(&apos;description&apos;) }}&lt;/textarea
        &gt;
        @error(&apos;description&apos;)
        &lt;p class=&quot;mt-1 text-sm text-red-600&quot;&gt;{{ $message }}&lt;/p&gt;
        @enderror
      &lt;/div&gt;

      {{-- 單價與人數（兩欄並排） --}}
      &lt;div class=&quot;grid grid-cols-1 md:grid-cols-3 gap-4&quot;&gt;
        &lt;div&gt;
          &lt;label for=&quot;price_per_unit&quot; class=&quot;block text-sm font-medium text-gray-700&quot;&gt;
            每份單價 (NT$) &lt;span class=&quot;text-red-500&quot;&gt;*&lt;/span&gt;
          &lt;/label&gt;
          &lt;input
            type=&quot;number&quot;
            name=&quot;price_per_unit&quot;
            id=&quot;price_per_unit&quot;
            value=&quot;{{ old(&apos;price_per_unit&apos;) }}&quot;
            min=&quot;1&quot;
            placeholder=&quot;250&quot;
            class=&quot;mt-1 w-full rounded-lg border px-4 py-2 @error(&apos;price_per_unit&apos;) border-red-500 @enderror&quot;
          /&gt;
          @error(&apos;price_per_unit&apos;)
          &lt;p class=&quot;mt-1 text-sm text-red-600&quot;&gt;{{ $message }}&lt;/p&gt;
          @enderror
        &lt;/div&gt;

        &lt;div&gt;
          &lt;label for=&quot;min_participants&quot; class=&quot;block text-sm font-medium text-gray-700&quot;&gt;
            最低成團人數 &lt;span class=&quot;text-red-500&quot;&gt;*&lt;/span&gt;
          &lt;/label&gt;
          &lt;input
            type=&quot;number&quot;
            name=&quot;min_participants&quot;
            id=&quot;min_participants&quot;
            value=&quot;{{ old(&apos;min_participants&apos;) }}&quot;
            min=&quot;2&quot;
            placeholder=&quot;5&quot;
            class=&quot;mt-1 w-full rounded-lg border px-4 py-2 @error(&apos;min_participants&apos;) border-red-500 @enderror&quot;
          /&gt;
          @error(&apos;min_participants&apos;)
          &lt;p class=&quot;mt-1 text-sm text-red-600&quot;&gt;{{ $message }}&lt;/p&gt;
          @enderror
        &lt;/div&gt;

        &lt;div&gt;
          &lt;label for=&quot;max_participants&quot; class=&quot;block text-sm font-medium text-gray-700&quot;&gt;
            人數上限
          &lt;/label&gt;
          &lt;input
            type=&quot;number&quot;
            name=&quot;max_participants&quot;
            id=&quot;max_participants&quot;
            value=&quot;{{ old(&apos;max_participants&apos;) }}&quot;
            placeholder=&quot;不限&quot;
            class=&quot;mt-1 w-full rounded-lg border px-4 py-2 @error(&apos;max_participants&apos;) border-red-500 @enderror&quot;
          /&gt;
          @error(&apos;max_participants&apos;)
          &lt;p class=&quot;mt-1 text-sm text-red-600&quot;&gt;{{ $message }}&lt;/p&gt;
          @enderror
        &lt;/div&gt;
      &lt;/div&gt;

      {{-- 截止時間 --}}
      &lt;div&gt;
        &lt;label for=&quot;deadline&quot; class=&quot;block text-sm font-medium text-gray-700&quot;&gt;
          截止時間 &lt;span class=&quot;text-red-500&quot;&gt;*&lt;/span&gt;
        &lt;/label&gt;
        &lt;input
          type=&quot;datetime-local&quot;
          name=&quot;deadline&quot;
          id=&quot;deadline&quot;
          value=&quot;{{ old(&apos;deadline&apos;) }}&quot;
          class=&quot;mt-1 w-full rounded-lg border px-4 py-2 @error(&apos;deadline&apos;) border-red-500 @enderror&quot;
        /&gt;
        &lt;p class=&quot;mt-1 text-xs text-gray-400&quot;&gt;截止時間必須是至少 24 小時後&lt;/p&gt;
        @error(&apos;deadline&apos;)
        &lt;p class=&quot;mt-1 text-sm text-red-600&quot;&gt;{{ $message }}&lt;/p&gt;
        @enderror
      &lt;/div&gt;

      {{-- 商品圖片 --}}
      &lt;div&gt;
        &lt;label for=&quot;image&quot; class=&quot;block text-sm font-medium text-gray-700&quot;&gt;商品圖片&lt;/label&gt;
        &lt;input
          type=&quot;file&quot;
          name=&quot;image&quot;
          id=&quot;image&quot;
          accept=&quot;image/jpeg,image/png,image/webp&quot;
          class=&quot;mt-1 w-full text-sm text-gray-500
                        file:mr-4 file:py-2 file:px-4
                        file:rounded-lg file:border-0
                        file:text-sm file:font-medium
                        file:bg-indigo-50 file:text-indigo-700
                        hover:file:bg-indigo-100&quot;
        /&gt;
        &lt;p class=&quot;mt-1 text-xs text-gray-400&quot;&gt;JPG、PNG、WebP 格式，最大 2MB&lt;/p&gt;
        @error(&apos;image&apos;)
        &lt;p class=&quot;mt-1 text-sm text-red-600&quot;&gt;{{ $message }}&lt;/p&gt;
        @enderror
      &lt;/div&gt;

      {{-- 送出按鈕 --}}
      &lt;div class=&quot;flex justify-end gap-4&quot;&gt;
        &lt;a href=&quot;/group-buys&quot; class=&quot;px-6 py-2 rounded-lg border text-gray-600 hover:bg-gray-50&quot;&gt;
          取消
        &lt;/a&gt;
        &lt;button
          type=&quot;submit&quot;
          class=&quot;px-6 py-2 rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 transition&quot;
        &gt;
          建立團購
        &lt;/button&gt;
      &lt;/div&gt;
    &lt;/form&gt;
  &lt;/div&gt;
&lt;/x-layouts.app&gt;
```

幾個重要細節：

1. **`enctype=&quot;multipart/form-data&quot;`**——有檔案上傳的表單一定要加這個，否則後端收不到檔案。這是很多新手會忘的坑。
2. **`@csrf`**——Laravel 的 CSRF 保護。沒有這個 token，POST 請求會被直接拒絕（419 status code）。
3. **`old(&apos;field&apos;)`**——每個 input 都用 `old()` 回填值，驗證失敗時使用者不用重新填寫。
4. **`@error(&apos;field&apos;)`**——每個欄位下面都有錯誤訊息區域，只在驗證失敗時顯示。
5. **`accept=&quot;image/jpeg,image/png,image/webp&quot;`**——前端的檔案類型限制，讓使用者在選檔案時只看到支援的格式。記住，這只是 UX 優化，後端的 `mimes` 規則才是真正的驗證。

### Step 4：路由

確認 `routes/web.php` 有這兩條路由（第六章應該已經加了）：

```php
Route::middleware([&apos;auth&apos;, &apos;verified&apos;])-&gt;group(function () {
    Route::get(&apos;/group-buys/create&apos;, [GroupBuyController::class, &apos;create&apos;]);
    Route::post(&apos;/group-buys&apos;, [GroupBuyController::class, &apos;store&apos;]);
});
```

### Step 5：驗證流程完整走一遍

```bash
php artisan serve
npm run dev
```

打開 `http://localhost:8000/group-buys/create`（需先登入）：

1. **什麼都不填直接送出** → 每個必填欄位下方都出現紅色錯誤訊息
2. **標題只填 1 個字** → 出現「標題至少要 3 個字」
3. **截止時間設成 2 小時後** → 出現「截止時間必須是至少 24 小時後」
4. **上傳一個 5MB 的 PNG** → 出現「圖片大小不能超過 2MB」
5. **全部填正確** → 建立成功，重新導向到團購詳情頁

全程沒寫任何 JavaScript，表單驗證、錯誤顯示、值回填全部由 Laravel + Blade 搞定。

### 更新團購時的驗證

建立（Store）和更新（Update）的驗證規則通常很像但不完全一樣。例如更新時圖片不是必要的（保留舊圖），`unique` 規則要排除自己。你可以另建一個 `UpdateGroupBuyRequest`：

```php
class UpdateGroupBuyRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this-&gt;user()-&gt;can(&apos;update&apos;, $this-&gt;route(&apos;groupBuy&apos;));
    }

    public function rules(): array
    {
        return [
            &apos;title&apos;            =&gt; [&apos;required&apos;, &apos;string&apos;, &apos;min:3&apos;, &apos;max:100&apos;],
            &apos;description&apos;      =&gt; [&apos;required&apos;, &apos;string&apos;, &apos;max:2000&apos;],
            &apos;product_name&apos;     =&gt; [&apos;required&apos;, &apos;string&apos;, &apos;max:100&apos;],
            &apos;price_per_unit&apos;   =&gt; [&apos;required&apos;, &apos;integer&apos;, &apos;min:1&apos;],
            &apos;min_participants&apos; =&gt; [&apos;required&apos;, &apos;integer&apos;, &apos;min:2&apos;, &apos;max:500&apos;],
            &apos;max_participants&apos; =&gt; [&apos;nullable&apos;, &apos;integer&apos;, &apos;gte:min_participants&apos;],
            &apos;deadline&apos;         =&gt; [&apos;required&apos;, &apos;date&apos;, &apos;after:today&apos;],
            // 更新時圖片完全可選
            &apos;image&apos;            =&gt; [&apos;nullable&apos;, &apos;image&apos;, &apos;mimes:jpg,png,webp&apos;, &apos;max:2048&apos;],
        ];
    }
}
```

在 Controller 裡更新時，要記得刪除舊圖片：

```php
public function update(UpdateGroupBuyRequest $request, GroupBuy $groupBuy)
{
    $groupBuy-&gt;update($request-&gt;validated());

    if ($request-&gt;hasFile(&apos;image&apos;)) {
        // 刪除舊圖片
        if ($groupBuy-&gt;image_path) {
            Storage::disk(&apos;public&apos;)-&gt;deleteDirectory(
                dirname($groupBuy-&gt;image_path)
            );
        }

        $groupBuy-&gt;update([
            &apos;image_path&apos; =&gt; $this-&gt;processImage($request-&gt;file(&apos;image&apos;), $groupBuy-&gt;id),
        ]);
    }

    return redirect(&quot;/group-buys/{$groupBuy-&gt;id}&quot;)
        -&gt;with(&apos;success&apos;, &apos;團購已更新&apos;);
}
```

## 小結：驗證是對使用者的尊重

好的驗證不只是擋壞資料——它是在告訴使用者「我知道你哪裡填錯了，這是正確的方向」。清楚的錯誤訊息、自動回填的表單值、友善的中文提示，這些細節讓使用者感受到產品的用心。

這一章我們走過了 Laravel 驗證與檔案上傳的完整流程：

- **後端驗證是唯一防線**——永遠不要信任前端，`curl` 一行就能繞過所有 JavaScript
- **`$request-&gt;validate()`**——最快的驗證方式，適合簡單場景
- **Form Request**——把驗證邏輯抽出 Controller，保持程式碼精簡。`rules()`、`authorize()`、`messages()` 三個方法各司其職
- **自訂規則**——Closure rule 快速搞定一次性需求，Rule class 處理可重用的業務規則
- **中文化**——`lang/zh_TW/validation.php` 搭配 `attributes` 設定，讓錯誤訊息說人話
- **Storage Facade**——統一的檔案操作 API，`store()`、`storeAs()`、`delete()`，切換 S3 只改設定檔
- **Intervention Image**——圖片縮放、裁剪、轉 WebP，產生適合網頁的多種尺寸

**揪好買進度：**

- ✅ `StoreGroupBuyRequest` 完整驗證規則
- ✅ `MinHoursFromNow` 自訂驗證規則
- ✅ 中文錯誤訊息與欄位名稱
- ✅ 「開團」表單完整 Blade 模板（含 `@error`、`old()`）
- ✅ 商品圖片上傳、縮圖生成
- ✅ Controller 保持精簡（每個 method 不超過十行核心邏輯）

下一章，我們要進入揪好買的心臟——[跟團與成團邏輯](/blog/laravel-guide-group-buy-logic-session)。使用者怎麼「+1」跟團、什麼時候成團、截止時間到了怎麼處理。你會學到 Laravel 的 Session 機制、Cache facade、以及怎麼把模糊的業務需求轉化成清楚的程式碼。</content:encoded><media:content url="https://bobochen.dev/_astro/cover.PpZ4vs4I.webp" medium="image"/><category>PHP</category><category>Laravel</category><category>Validation</category><category>File Upload</category><category>Form Request</category><enclosure url="https://bobochen.dev/_astro/cover.PpZ4vs4I.webp" length="0" type="image/png"/></item><item><title>Laravel 認證與授權：用 Starter Kit 十分鐘搞定會員系統</title><link>https://bobochen.dev/blog/laravel-guide-auth-breeze-authorization/</link><guid isPermaLink="true">https://bobochen.dev/blog/laravel-guide-auth-breeze-authorization/</guid><description>每個有使用者的應用都逃不過認證與授權。本章用 Laravel 12 官方 Starter Kit（Livewire 版）一行指令搞定註冊、登入、忘記密碼與 Email 驗證，再用 Gate 與 Policy 做細粒度授權，並示範以 role 欄位與 Enum 實作角色權限，打造完整會員系統。</description><pubDate>Tue, 08 Apr 2025 00:00:00 GMT</pubDate><content:encoded>每一個有使用者的應用程式，都逃不過這兩件事：認證（Authentication）和授權（Authorization）。認證是「你是誰」，授權是「你能做什麼」。聽起來簡單，但自己從零刻一套會員系統——處理密碼雜湊、Session 管理、忘記密碼信件、Email 驗證、CSRF 防護——光想就頭痛。更何況，這些東西稍有閃失就是資安漏洞。

好消息是，Laravel 根本不讓你自己造這個輪子。Laravel 12 推出了全新的官方 **Starter Kit**，取代了舊版的 Breeze 和 Jetstream。一行指令就幫你搞定註冊、登入、密碼重設、Email 驗證的完整流程，連前端 UI 都幫你生好了。你可以選擇 React、Vue、Livewire（推出時的三種官方選項，2026 年 2 月再補上 Svelte）作為前端堆疊——我們選 Livewire，因為整本書都在 PHP 生態系裡。

在「揪好買」團購平台裡，我們有兩種角色：開團主和跟團者。開團主可以建立團購、設定截止時間、管理訂單；跟團者只能瀏覽和加入。這一章，我們要用 Starter Kit 搞定會員系統，再用 Policy 把「只有開團主能建立團購」這條規則寫得乾淨俐落。

## 認證 vs 授權：先搞清楚這兩件事

這兩個詞經常被混用，但它們是完全不同的概念：

|              | 認證（Authentication） | 授權（Authorization）  |
| ------------ | ---------------------- | ---------------------- |
| 問題         | 你是誰？               | 你能做什麼？           |
| 時機         | 登入時                 | 每次操作時             |
| 失敗結果     | 401 Unauthorized       | 403 Forbidden          |
| Laravel 工具 | Guard、Session、Token  | Gate、Policy           |
| 類比         | 門口刷員工證           | 你的員工證能進哪些房間 |

認證通常只需要做一次（用套件），授權則貫穿整個應用程式（用你的業務邏輯）。這一章前半講認證，後半講授權。

### 跨框架對照

| 概念       | Laravel        | Express (Node.js) | Django (Python)          |
| ---------- | -------------- | ----------------- | ------------------------ |
| 認證套件   | Starter Kit    | Passport.js       | django.contrib.auth      |
| Session    | 內建           | express-session   | 內建                     |
| 密碼雜湊   | `Hash::make()` | bcrypt            | `make_password()`        |
| 授權       | Gate / Policy  | 自己寫 middleware | Permissions / Decorators |
| Token 認證 | Sanctum        | jsonwebtoken      | DRF TokenAuth            |

## Laravel 12 Starter Kit：十分鐘擁有完整會員系統

### 什麼是 Starter Kit？

Laravel 12 的 Starter Kit 是官方維護的**應用程式啟動模板**。它直接把認證相關的 Controller、View、Route 全部放進你的專案裡——不是 Composer 套件，而是真正的程式碼。你可以看到每一行、改每一處。

&gt; **歷史脈絡：** Laravel 11 以前用的是 Breeze（輕量）和 Jetstream（重量級，含 Team 管理）。Laravel 12 把它們統一成了新的 Starter Kit 系列，推出時提供 React、Vue、Livewire 三種前端選項，2026 年 2 月再加入官方 Svelte + Inertia kit，目前共四種。如果你看到舊教學提到 Breeze，概念是一樣的，只是安裝方式不同。

### 安裝 Livewire Starter Kit

如果你在[第二章建專案](/blog/laravel-guide-setup-first-route/)時選了 &quot;No starter kit&quot;，現在可以重新建一個：

```bash
laravel new jiu-hao-mai
```

在互動式選單中選擇：

```text
 ┌ Would you like to install a starter kit? ──────┐
 │ › Livewire                                       │
 └──────────────────────────────────────────────────┘
```

或者，如果你想在現有專案上操作，最簡單的做法是用 `laravel new` 建一個新專案，再把認證相關的檔案複製過來。

### 安裝完你得到了什麼？

Starter Kit 幫你生成的東西：

```text
app/
└── Livewire/Auth/               # 認證邏輯放在 Livewire 元件，而非傳統 Controller
    ├── Login.php
    ├── Register.php
    ├── ResetPassword.php
    └── VerifyEmail.php
resources/views/
├── livewire/auth/               # 認證頁面模板
│   ├── login.blade.php
│   ├── register.blade.php
│   └── ...
├── components/layouts/
│   └── app.blade.php            # 應用程式 Layout
routes/
└── auth.php                     # 認證路由（Volt::route 風格）
```

&gt; **注意：** Laravel 12 Livewire Starter Kit **不會**產生傳統的 `app/Http/Controllers/Auth/` 目錄。認證邏輯改用 Livewire 元件（`app/Livewire/Auth/`）處理，不再是 LoginController、RegisterController 等傳統 Controller。

所有程式碼都在你的專案裡——不是躲在 `vendor/` 裡的黑盒子。

## 註冊、登入、忘記密碼：開箱即用

安裝完 Starter Kit 後，這些路由就自動可用了：

| 路由                          | 功能           |
| ----------------------------- | -------------- |
| `GET /register`               | 註冊頁面       |
| `POST /register`              | 處理註冊       |
| `GET /login`                  | 登入頁面       |
| `POST /login`                 | 處理登入       |
| `POST /logout`                | 登出           |
| `GET /forgot-password`        | 忘記密碼頁面   |
| `POST /forgot-password`       | 寄送重設密碼信 |
| `GET /reset-password/{token}` | 重設密碼頁面   |
| `POST /reset-password`        | 處理密碼重設   |

```bash
php artisan serve
# 打開 http://localhost:8000/register
```

你會看到一個設計好的註冊頁面。填入資料、送出，帳號就建好了。登入、登出、忘記密碼——全部能用。

### 在 Controller 裡取得當前使用者

```php
use Illuminate\Http\Request;

class DashboardController extends Controller
{
    public function index(Request $request)
    {
        // 方法一：從 Request 物件取
        $user = $request-&gt;user();

        // 方法二：用 Auth Facade
        $user = auth()-&gt;user();

        // 方法三：取得使用者 ID
        $userId = auth()-&gt;id();

        // 檢查是否已登入
        if (auth()-&gt;check()) {
            // 已登入
        }

        return view(&apos;dashboard&apos;, compact(&apos;user&apos;));
    }
}
```

### 保護路由（要求登入才能存取）

```php
// routes/web.php

// 方法一：單一路由
Route::get(&apos;/dashboard&apos;, [DashboardController::class, &apos;index&apos;])
    -&gt;middleware(&apos;auth&apos;);

// 方法二：路由群組（常用）
Route::middleware(&apos;auth&apos;)-&gt;group(function () {
    Route::get(&apos;/dashboard&apos;, [DashboardController::class, &apos;index&apos;]);
    Route::get(&apos;/my-groups&apos;, [GroupBuyController::class, &apos;myGroups&apos;]);
    Route::post(&apos;/group-buys&apos;, [GroupBuyController::class, &apos;store&apos;]);
});

// 方法三：只給「未登入」使用者（如登入頁）
Route::get(&apos;/login&apos;, [LoginController::class, &apos;show&apos;])
    -&gt;middleware(&apos;guest&apos;);
```

`auth` middleware 的邏輯很簡單：使用者沒登入 → 重新導向到 `/login`。就這樣。

### 密碼雜湊

Laravel 自動用 bcrypt 雜湊密碼，你永遠不會在資料庫裡看到明文密碼：

```php
use Illuminate\Support\Facades\Hash;

// 建立雜湊（註冊時用）
$hashed = Hash::make(&apos;my-password&apos;);
// $2y$12$eUxcJq1...（60 字元雜湊字串）

// 驗證密碼（登入時用）
if (Hash::check(&apos;my-password&apos;, $hashed)) {
    // 密碼正確
}
```

&gt; **重要：** 永遠不要自己寫登入驗證邏輯。Starter Kit 已經幫你處理好了密碼雜湊、防暴力破解（rate limiting）、CSRF 防護等所有安全細節。

## Email 驗證：確保使用者是真人

Email 驗證是「註冊後寄一封確認信，使用者點連結才算驗證完成」的功能。

### 啟用 Email 驗證

讓 User Model 實作 `MustVerifyEmail` 介面：

```php
// app/Models/User.php
use Illuminate\Contracts\Auth\MustVerifyEmail;

class User extends Authenticatable implements MustVerifyEmail
{
    // ...
}
```

就這一行，Laravel 會自動：

- 在註冊後寄出驗證信
- 提供 `/verify-email` 頁面
- 驗證連結有簽名保護（防偽造）

### 限制未驗證使用者

```php
Route::middleware([&apos;auth&apos;, &apos;verified&apos;])-&gt;group(function () {
    // 這裡的路由只有驗證過 email 的使用者才能進
    Route::post(&apos;/group-buys&apos;, [GroupBuyController::class, &apos;store&apos;]);
});
```

### 開發環境的 Email

開發階段不用真的寄信。`.env` 預設用 `log` driver：

```bash
MAIL_MAILER=log
```

所有「寄出」的信件都會記錄在 `storage/logs/laravel.log` 裡，你可以從 log 裡找到驗證連結。

## Gate 與 Policy：誰可以做什麼事

認證搞定了（你是誰），現在來處理授權（你能做什麼）。

### Gate：簡單的授權檢查

Gate 是最基本的授權方式——定義一個 closure，回傳 `true` 或 `false`：

```php
// app/Providers/AppServiceProvider.php 的 boot() 裡
use Illuminate\Support\Facades\Gate;
use App\Models\GroupBuy;
use App\Models\User;

public function boot(): void
{
    Gate::define(&apos;create-group-buy&apos;, function (User $user) {
        return $user-&gt;is_organizer;
    });

    Gate::define(&apos;update-group-buy&apos;, function (User $user, GroupBuy $groupBuy) {
        return $user-&gt;id === $groupBuy-&gt;user_id;
    });
}
```

使用方式：

```php
// 在 Controller 裡
if (Gate::allows(&apos;create-group-buy&apos;)) {
    // 可以建立團購
}

if (Gate::denies(&apos;update-group-buy&apos;, $groupBuy)) {
    abort(403);
}

// 更簡潔：authorize（失敗自動丟 403）
Gate::authorize(&apos;create-group-buy&apos;);

// 在 Blade 裡
@can(&apos;create-group-buy&apos;)
    &lt;a href=&quot;/group-buys/create&quot;&gt;+ 我要開團&lt;/a&gt;
@endcan

@cannot(&apos;update-group-buy&apos;, $groupBuy)
    &lt;p&gt;你沒有權限編輯這個團購&lt;/p&gt;
@endcannot
```

### Policy：更有組織的授權

Gate 適合一兩條簡單規則。當授權邏輯變多，**Policy** 把同一個 Model 的授權規則集中在一個 class 裡：

```bash
php artisan make:policy GroupBuyPolicy --model=GroupBuy
```

```php
&lt;?php

namespace App\Policies;

use App\Models\GroupBuy;
use App\Models\User;

class GroupBuyPolicy
{
    /**
     * 誰可以看列表？所有人。
     */
    public function viewAny(?User $user): bool
    {
        return true;  // ?User 表示未登入也可以
    }

    /**
     * 誰可以看單一團購？所有人。
     */
    public function view(?User $user, GroupBuy $groupBuy): bool
    {
        return true;
    }

    /**
     * 誰可以建立團購？已驗證 email 的使用者。
     */
    public function create(User $user): bool
    {
        return $user-&gt;hasVerifiedEmail();
    }

    /**
     * 誰可以更新團購？只有開團者本人。
     */
    public function update(User $user, GroupBuy $groupBuy): bool
    {
        return $user-&gt;id === $groupBuy-&gt;user_id;
    }

    /**
     * 誰可以刪除團購？
     * 只有開團者本人，而且團購還沒有人加入。
     */
    public function delete(User $user, GroupBuy $groupBuy): bool
    {
        return $user-&gt;id === $groupBuy-&gt;user_id
            &amp;&amp; $groupBuy-&gt;participants()-&gt;count() === 0;
    }
}
```

### 在 Controller 裡使用 Policy

```php
class GroupBuyController extends Controller
{
    public function index()
    {
        // viewAny 不需要特定 model 實例
        $this-&gt;authorize(&apos;viewAny&apos;, GroupBuy::class);

        return view(&apos;group-buys.index&apos;);
    }

    public function create()
    {
        $this-&gt;authorize(&apos;create&apos;, GroupBuy::class);

        return view(&apos;group-buys.create&apos;);
    }

    public function store(Request $request)
    {
        $this-&gt;authorize(&apos;create&apos;, GroupBuy::class);

        // 建立團購...
    }

    public function edit(GroupBuy $groupBuy)
    {
        $this-&gt;authorize(&apos;update&apos;, $groupBuy);

        return view(&apos;group-buys.edit&apos;, compact(&apos;groupBuy&apos;));
    }

    public function destroy(GroupBuy $groupBuy)
    {
        $this-&gt;authorize(&apos;delete&apos;, $groupBuy);

        $groupBuy-&gt;delete();

        return redirect(&apos;/group-buys&apos;)-&gt;with(&apos;success&apos;, &apos;團購已刪除&apos;);
    }
}
```

`$this-&gt;authorize()` 會自動找到 `GroupBuyPolicy`（靠命名慣例），檢查對應的方法。失敗就丟 403 Forbidden。

&gt; **Policy 的自動發現：** Laravel 會自動把 `GroupBuy` Model 對應到 `GroupBuyPolicy`。你不需要手動註冊——命名慣例搞定一切。

### 在 Blade 裡使用 Policy

```blade
@can(&apos;create&apos;, App\Models\GroupBuy::class)
&lt;a href=&quot;/group-buys/create&quot; class=&quot;btn-primary&quot;&gt;+ 我要開團&lt;/a&gt;
@endcan

@can(&apos;update&apos;, $groupBuy)
&lt;a href=&quot;/group-buys/{{ $groupBuy-&gt;id }}/edit&quot;&gt;編輯&lt;/a&gt;
@endcan

@can(&apos;delete&apos;, $groupBuy)
&lt;form method=&quot;POST&quot; action=&quot;/group-buys/{{ $groupBuy-&gt;id }}&quot;&gt;
  @csrf
  @method(&apos;DELETE&apos;)
  &lt;button type=&quot;submit&quot; class=&quot;text-red-600&quot;&gt;刪除&lt;/button&gt;
&lt;/form&gt;
@endcan
```

## Role-based 權限設計模式

揪好買需要區分「開團主」和「跟團者」。最簡單的做法是在 `users` 表加一個 `role` 欄位：

### 方案一：簡單的 role 欄位

```bash
php artisan make:migration add_role_to_users_table
```

```php
public function up(): void
{
    Schema::table(&apos;users&apos;, function (Blueprint $table) {
        $table-&gt;string(&apos;role&apos;)-&gt;default(&apos;member&apos;);  // member, organizer, admin
    });
}
```

在 User Model 加上 helper 方法：

```php
// app/Models/User.php

// 用 Enum 定義角色（型別安全）
enum UserRole: string
{
    case Member = &apos;member&apos;;
    case Organizer = &apos;organizer&apos;;
    case Admin = &apos;admin&apos;;
}

class User extends Authenticatable implements MustVerifyEmail
{
    protected function casts(): array
    {
        return [
            &apos;email_verified_at&apos; =&gt; &apos;datetime&apos;,
            &apos;password&apos; =&gt; &apos;hashed&apos;,
            &apos;role&apos; =&gt; UserRole::class,
        ];
    }

    public function isOrganizer(): bool
    {
        return $this-&gt;role === UserRole::Organizer
            || $this-&gt;role === UserRole::Admin;
    }

    public function isAdmin(): bool
    {
        return $this-&gt;role === UserRole::Admin;
    }
}
```

然後在 Policy 裡使用：

```php
public function create(User $user): bool
{
    return $user-&gt;isOrganizer() &amp;&amp; $user-&gt;hasVerifiedEmail();
}
```

### 方案二：Spatie Permission 套件（多角色/多權限）

如果需要更複雜的權限系統（一個使用者可以有多個角色、角色可以有多個權限），推薦用 [spatie/laravel-permission](https://spatie.be/docs/laravel-permission)：

```bash
composer require spatie/laravel-permission
php artisan vendor:publish --provider=&quot;Spatie\Permission\PermissionServiceProvider&quot;
php artisan migrate
```

```php
// 指派角色
$user-&gt;assignRole(&apos;organizer&apos;);

// 檢查角色
$user-&gt;hasRole(&apos;organizer&apos;);

// 指派權限
$user-&gt;givePermissionTo(&apos;create group buys&apos;);

// 在 Policy 裡用
public function create(User $user): bool
{
    return $user-&gt;can(&apos;create group buys&apos;);
}
```

&gt; **揪好買用哪個？** 我們的需求很簡單（member / organizer / admin 三種角色），方案一的 `role` 欄位就夠了。除非你的專案有「一個使用者同時是多個角色」或「權限需要動態新增/移除」的需求，才需要 Spatie Permission。YAGNI——You Ain&apos;t Gonna Need It。

## Guard：多重認證系統

Guard 決定的是「用什麼方式認證使用者」。預設的 `web` guard 用 Session + Cookie，`api` guard 用 Token。

大多數應用只用一個 Guard，你不需要動它。只有在這些情況下才需要自訂 Guard：

- **前後台分離**——Admin 和一般使用者用不同的 users 表
- **API 認證**——用 Sanctum token 而不是 Session
- **多租戶**——不同租戶有不同的認證方式

```php
// config/auth.php（通常不需要改）
&apos;guards&apos; =&gt; [
    &apos;web&apos; =&gt; [
        &apos;driver&apos; =&gt; &apos;session&apos;,
        &apos;provider&apos; =&gt; &apos;users&apos;,
    ],
    // 安裝 Sanctum 後會多一個 sanctum guard
],
```

揪好買目前只需要 `web` guard，第十一章做 API 時才會用到 [Sanctum](/blog/laravel-guide-api-sanctum-rest/)。

## 實作：揪好買的開團主與跟團者

讓我們把認證和授權整合進揪好買。

### Step 1：加入 role 欄位

```bash
php artisan make:migration add_role_to_users_table
```

```php
public function up(): void
{
    Schema::table(&apos;users&apos;, function (Blueprint $table) {
        $table-&gt;string(&apos;role&apos;)-&gt;default(&apos;member&apos;);
    });
}
```

```bash
php artisan migrate
```

### Step 2：更新 User Model

在 `app/Models/User.php` 加入：

```php
use App\Enums\UserRole;

// 在 $fillable 加入 &apos;role&apos;
protected $fillable = [
    &apos;name&apos;, &apos;email&apos;, &apos;password&apos;, &apos;role&apos;,
];

protected function casts(): array
{
    return [
        &apos;email_verified_at&apos; =&gt; &apos;datetime&apos;,
        &apos;password&apos; =&gt; &apos;hashed&apos;,
        &apos;role&apos; =&gt; UserRole::class,
    ];
}

public function isOrganizer(): bool
{
    return $this-&gt;role === UserRole::Organizer
        || $this-&gt;role === UserRole::Admin;
}

public function isAdmin(): bool
{
    return $this-&gt;role === UserRole::Admin;
}
```

建立 Enum `app/Enums/UserRole.php`：

```php
&lt;?php

namespace App\Enums;

enum UserRole: string
{
    case Member = &apos;member&apos;;
    case Organizer = &apos;organizer&apos;;
    case Admin = &apos;admin&apos;;

    public function label(): string
    {
        return match ($this) {
            self::Member =&gt; &apos;一般會員&apos;,
            self::Organizer =&gt; &apos;開團主&apos;,
            self::Admin =&gt; &apos;管理員&apos;,
        };
    }
}
```

### Step 3：建立 GroupBuyPolicy

```bash
php artisan make:policy GroupBuyPolicy --model=GroupBuy
```

把前面的 Policy 程式碼放進去（viewAny、view、create、update、delete）。

### Step 4：保護路由

```php
// routes/web.php
use App\Http\Controllers\GroupBuyController;

// 任何人都能看
Route::get(&apos;/group-buys&apos;, [GroupBuyController::class, &apos;index&apos;]);
Route::get(&apos;/group-buys/{groupBuy}&apos;, [GroupBuyController::class, &apos;show&apos;]);

// 需要登入 + Email 驗證
Route::middleware([&apos;auth&apos;, &apos;verified&apos;])-&gt;group(function () {
    Route::get(&apos;/group-buys/create&apos;, [GroupBuyController::class, &apos;create&apos;]);
    Route::post(&apos;/group-buys&apos;, [GroupBuyController::class, &apos;store&apos;]);
    Route::get(&apos;/group-buys/{groupBuy}/edit&apos;, [GroupBuyController::class, &apos;edit&apos;]);
    Route::put(&apos;/group-buys/{groupBuy}&apos;, [GroupBuyController::class, &apos;update&apos;]);
    Route::delete(&apos;/group-buys/{groupBuy}&apos;, [GroupBuyController::class, &apos;destroy&apos;]);
});
```

### Step 5：在 View 裡顯示角色相關 UI

```blade
{{-- 導覽列 --}}
&lt;nav&gt;
  @auth
  &lt;span&gt;{{ auth()-&gt;user()-&gt;name }}（{{ auth()-&gt;user()-&gt;role-&gt;label() }}）&lt;/span&gt;

  @can(&apos;create&apos;, App\Models\GroupBuy::class)
  &lt;a href=&quot;/group-buys/create&quot;&gt;+ 我要開團&lt;/a&gt;
  @endcan
  @endauth
&lt;/nav&gt;

{{-- 團購詳情頁 --}}
@can(&apos;update&apos;, $groupBuy)
&lt;a href=&quot;/group-buys/{{ $groupBuy-&gt;id }}/edit&quot; class=&quot;btn&quot;&gt;編輯團購&lt;/a&gt;
@endcan

@can(&apos;delete&apos;, $groupBuy)
&lt;form method=&quot;POST&quot; action=&quot;/group-buys/{{ $groupBuy-&gt;id }}&quot;&gt;
  @csrf
  @method(&apos;DELETE&apos;)
  &lt;button onclick=&quot;return confirm(&apos;確定要刪除？&apos;)&quot; class=&quot;text-red-600&quot;&gt;刪除&lt;/button&gt;
&lt;/form&gt;
@endcan
```

&gt; **講真的，這一步最容易讓新手誤會：`@can` 只是把按鈕藏起來，它不是防線。** 編輯按鈕看不到，不代表那個 endpoint 被擋住了。任何人打開 DevTools、或直接用 curl／Postman 對著 `PUT /group-buys/5` 送一發請求，照樣會打進你的 Controller。真正的防線在後端——所以每一個會改資料的動作（store / update / destroy）都得自己再 `authorize()` 一次。少了這層，就是教科書等級的 IDOR 越權漏洞：A 開的團，B 改網址裡的 id 就能刪。

所以 Step 4 在路由套 `auth + verified` 還不夠——那只擋「沒登入的人」，擋不了「登入了但不該動這筆資料的人」。把前面那個 `GroupBuyController` 的 `authorize()` 真的接上去，每個寫入動作都檢查一次：

```php
class GroupBuyController extends Controller
{
    public function store(Request $request)
    {
        $this-&gt;authorize(&apos;create&apos;, GroupBuy::class);
        // 通過才建立團購...
    }

    public function update(Request $request, GroupBuy $groupBuy)
    {
        $this-&gt;authorize(&apos;update&apos;, $groupBuy);
        // 通過才更新...
    }

    public function destroy(GroupBuy $groupBuy)
    {
        $this-&gt;authorize(&apos;delete&apos;, $groupBuy);
        $groupBuy-&gt;delete();

        return redirect(&apos;/group-buys&apos;)-&gt;with(&apos;success&apos;, &apos;團購已刪除&apos;);
    }
}
```

懶一點也可以走路由層，把檢查直接掛在路由上，效果一樣：`Route::put(&apos;/group-buys/{groupBuy}&apos;, ...)-&gt;can(&apos;update&apos;, &apos;groupBuy&apos;);`。重點不是用哪種寫法，是「Blade 藏按鈕」跟「後端擋請求」永遠要成對出現，缺一個都不算授權做完。

### Step 6：Seeder 建立測試帳號

```php
// database/seeders/DatabaseSeeder.php
public function run(): void
{
    // 建立管理員
    User::factory()-&gt;create([
        &apos;name&apos; =&gt; &apos;Admin&apos;,
        &apos;email&apos; =&gt; &apos;admin@jiuhaomai.tw&apos;,
        &apos;role&apos; =&gt; &apos;admin&apos;,
    ]);

    // 建立開團主
    User::factory()-&gt;count(3)-&gt;create([
        &apos;role&apos; =&gt; &apos;organizer&apos;,
    ]);

    // 建立一般會員
    User::factory()-&gt;count(10)-&gt;create([
        &apos;role&apos; =&gt; &apos;member&apos;,
    ]);

    // ...GroupBuy seeder
}
```

```bash
php artisan migrate:fresh --seed
```

現在你可以用 `admin@jiuhaomai.tw` 登入，看到所有管理功能；用一般會員登入，只能看和跟團。

## 小結：認證用套件，授權用 Policy

這一章我們走過了 Laravel 認證授權的完整流程：

**認證（你是誰）：**

- Laravel 12 Starter Kit（Livewire 版）——一行安裝，開箱即用
- 註冊、登入、登出、忘記密碼、Email 驗證全自動
- `auth` middleware 保護路由，`auth()-&gt;user()` 取得當前使用者
- 密碼自動 bcrypt 雜湊，永遠不要自己處理密碼

**授權（你能做什麼）：**

- Gate——簡單的一次性授權檢查
- Policy——依 Model 組織的授權類別，自動發現
- `$this-&gt;authorize()` 在 Controller 裡使用，`@can` 在 Blade 裡使用
- Role-based 權限——簡單需求用 `role` 欄位 + Enum，複雜需求用 Spatie Permission

**揪好買進度：**

- ✅ User Model 加入 `role` 欄位（member / organizer / admin）
- ✅ UserRole Enum 定義角色和中文標籤
- ✅ GroupBuyPolicy 定義 CRUD 權限規則
- ✅ 路由保護（`auth` + `verified` middleware）
- ✅ Blade 裡用 `@can` 顯示/隱藏操作按鈕

下一章，我們要讓開團主真正地建立團購——[表單驗證與檔案上傳](/blog/laravel-guide-validation-file-upload/)。你會學到 Laravel 強大的 Validation 規則系統、Form Request、以及用 Storage facade 處理商品圖片上傳。</content:encoded><media:content url="https://bobochen.dev/_astro/cover.B-g-TAeN.webp" medium="image"/><category>PHP</category><category>Laravel</category><category>Authentication</category><category>Authorization</category><category>Starter Kit</category><category>Livewire</category><enclosure url="https://bobochen.dev/_astro/cover.B-g-TAeN.webp" length="0" type="image/png"/></item><item><title>Blade + Livewire：打造互動式前端不需要寫 JavaScript</title><link>https://bobochen.dev/blog/laravel-guide-blade-livewire-frontend/</link><guid isPermaLink="true">https://bobochen.dev/blog/laravel-guide-blade-livewire-frontend/</guid><description>用 Blade 模板引擎與 Livewire 3，完全不寫 JavaScript 也能打造互動式前端：可重用的 Blade Component、wire:model 即時搜尋與篩選、Volt 單檔元件，再搭配 Alpine.js 處理純 UI 互動與 Tailwind CSS 美化，做出流暢的全 PHP 前端體驗。</description><pubDate>Tue, 01 Apr 2025 00:00:00 GMT</pubDate><content:encoded>「用 Laravel 做前端？不是應該接 React 或 Vue 嗎？」——這是很多人的第一反應。確實，Laravel 有完整的 API 支援可以搭配任何前端框架。但 Laravel 社群在過去幾年發展出了另一條路線：**用純 PHP 寫前端互動**。這條路的核心就是 Blade 模板引擎加上 Livewire。

Blade 是 Laravel 內建的模板引擎，讓你在 HTML 裡面嵌入 PHP 邏輯，類似 JSX 之於 React 或 Jinja 之於 Python。有了 Blade，你可以用 `@if`、`@foreach` 等指令描述頁面邏輯，同時保持 HTML 的可讀性。

Livewire 3 更進一步：它讓你用 PHP 類別定義前端元件的狀態和行為，使用者的點擊、輸入、篩選等互動動作，全部由 Livewire 透過 AJAX 在背景處理，頁面局部更新，完全不需要你手寫 JavaScript。再搭配 Alpine.js 處理一些純前端的小互動（下拉選單、Modal），你就有了一套完整的全 PHP 前端開發體驗。

這一章我們要幫揪好買打造使用者看得到的介面：團購列表頁（支援即時搜尋和篩選）、團購詳情頁（顯示即時參與人數）、可重用的 UI 元件。你會發現不寫 JavaScript 也能做出流暢的互動體驗——而且程式碼比你想像的少很多。

## Blade 模板引擎：Laravel 的 HTML 超能力

[第二章](/blog/laravel-guide-setup-first-route/)我們已經碰過 Blade 的基本語法——`{{ }}`、`@if`、`@foreach`、`@extends`。這一節我們再補充幾個進階但很常用的功能。

### 條件渲染

```html
{{-- 顯示/隱藏 --}}
@if($groupBuy-&gt;status === &apos;open&apos;)
&lt;span class=&quot;badge-green&quot;&gt;開團中&lt;/span&gt;
@elseif($groupBuy-&gt;status === &apos;confirmed&apos;)
&lt;span class=&quot;badge-blue&quot;&gt;已成團&lt;/span&gt;
@else
&lt;span class=&quot;badge-gray&quot;&gt;已結束&lt;/span&gt;
@endif

{{-- 更簡潔的語法 --}}
@unless($groupBuy-&gt;isFull())
&lt;button&gt;我要跟團&lt;/button&gt;
@endunless

{{-- 有/沒有資料 --}}
@forelse($groupBuys as $groupBuy)
&lt;div&gt;{{ $groupBuy-&gt;title }}&lt;/div&gt;
@empty
&lt;p&gt;目前沒有開團中的團購&lt;/p&gt;
@endforelse
```

### 認證相關

```html
@auth
&lt;p&gt;歡迎回來，{{ auth()-&gt;user()-&gt;name }}！&lt;/p&gt;
@endauth @guest
&lt;a href=&quot;/login&quot;&gt;登入&lt;/a&gt;
&lt;a href=&quot;/register&quot;&gt;註冊&lt;/a&gt;
@endguest
```

### 引入子 View

```html
{{-- 引入另一個 Blade 檔案 --}} @include(&apos;partials.navbar&apos;) {{-- 引入時傳資料 --}}
@include(&apos;partials.group-buy-card&apos;, [&apos;groupBuy&apos; =&gt; $groupBuy])
```

## Layout 與 Component：可重用的 UI 積木

第二章用的是 `@extends` + `@yield` 的傳統 Layout 方式。現代 Laravel 更推薦用 **Blade Component**——語法更像 HTML，組合性更強。

### Component Layout

建立 `resources/views/components/layouts/app.blade.php`：

```html
&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;zh-TW&quot;&gt;
  &lt;head&gt;
    &lt;meta charset=&quot;UTF-8&quot; /&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot; /&gt;
    &lt;title&gt;{{ $title ?? &apos;揪好買&apos; }} | 揪好買 JiuHaoMai&lt;/title&gt;
    @vite([&apos;resources/css/app.css&apos;, &apos;resources/js/app.js&apos;])
  &lt;/head&gt;
  &lt;body class=&quot;bg-gray-50 min-h-screen&quot;&gt;
    &lt;nav class=&quot;bg-indigo-900 text-white px-6 py-4&quot;&gt;
      &lt;div class=&quot;max-w-5xl mx-auto flex justify-between items-center&quot;&gt;
        &lt;a href=&quot;/&quot; class=&quot;text-xl font-bold text-emerald-400&quot;&gt;🛒 揪好買&lt;/a&gt;
        &lt;div class=&quot;space-x-4&quot;&gt;
          &lt;a href=&quot;/group-buys&quot; class=&quot;hover:text-emerald-300&quot;&gt;所有團購&lt;/a&gt;
          @auth
          &lt;a href=&quot;/my-groups&quot; class=&quot;hover:text-emerald-300&quot;&gt;我的團購&lt;/a&gt;
          @endauth
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/nav&gt;

    &lt;main class=&quot;max-w-5xl mx-auto px-6 py-8&quot;&gt;{{ $slot }}&lt;/main&gt;

    &lt;footer class=&quot;text-center py-6 text-gray-400 text-sm&quot;&gt;&amp;copy; 2026 揪好買 JiuHaoMai&lt;/footer&gt;
  &lt;/body&gt;
&lt;/html&gt;
```

使用方式——像 HTML 標籤一樣包裹內容：

```html
&lt;x-layouts.app title=&quot;團購列表&quot;&gt;
  &lt;h1&gt;所有團購&lt;/h1&gt;
  &lt;p&gt;這裡的內容會填入 Layout 的 {{ $slot }}&lt;/p&gt;
&lt;/x-layouts.app&gt;
```

&gt; `{{ $slot }}` 是預設的內容插槽。所有 `&lt;x-layouts.app&gt;` 標籤之間的東西都會自動填入 `$slot`。`$title` 則是傳給 Component 的屬性。

### 自訂 Blade Component

把重複出現的 UI 抽成 Component：

```bash
php artisan make:component GroupBuyCard
```

這會建立兩個檔案：

1. `app/View/Components/GroupBuyCard.php`——PHP 類別（邏輯）
2. `resources/views/components/group-buy-card.blade.php`——Blade 模板（UI）

```php
// app/View/Components/GroupBuyCard.php
&lt;?php

namespace App\View\Components;

use App\Models\GroupBuy;
use Illuminate\View\Component;

class GroupBuyCard extends Component
{
    public function __construct(
        public GroupBuy $groupBuy,
    ) {}

    public function participantCount(): int
    {
        return $this-&gt;groupBuy-&gt;participants()-&gt;count();
    }

    public function render()
    {
        return view(&apos;components.group-buy-card&apos;);
    }
}
```

```html
&lt;!-- resources/views/components/group-buy-card.blade.php --&gt;
&lt;div class=&quot;bg-white rounded-xl shadow-sm border p-5 hover:shadow-md transition&quot;&gt;
  &lt;div class=&quot;flex justify-between items-start&quot;&gt;
    &lt;h3 class=&quot;font-bold text-lg&quot;&gt;{{ $groupBuy-&gt;title }}&lt;/h3&gt;
    &lt;span
      class=&quot;text-sm px-2 py-1 rounded
            {{ $groupBuy-&gt;status === &apos;open&apos; ? &apos;bg-green-100 text-green-700&apos; : &apos;bg-gray-100 text-gray-500&apos; }}&quot;
    &gt;
      {{ $groupBuy-&gt;status === &apos;open&apos; ? &apos;開團中&apos; : &apos;已結束&apos; }}
    &lt;/span&gt;
  &lt;/div&gt;

  &lt;p class=&quot;text-gray-500 mt-2 text-sm&quot;&gt;{{ Str::limit($groupBuy-&gt;description, 60) }}&lt;/p&gt;

  &lt;div class=&quot;mt-4 flex justify-between items-center text-sm&quot;&gt;
    &lt;span&gt;💰 ${{ number_format($groupBuy-&gt;price_per_unit / 100) }} / 份&lt;/span&gt;
    &lt;span&gt;👥 {{ $participantCount() }} / {{ $groupBuy-&gt;min_participants }} 人&lt;/span&gt;
    &lt;span&gt;⏰ {{ $groupBuy-&gt;deadline-&gt;diffForHumans() }}&lt;/span&gt;
  &lt;/div&gt;

  &lt;a
    href=&quot;/group-buys/{{ $groupBuy-&gt;id }}&quot;
    class=&quot;block mt-4 text-center bg-indigo-600 text-white py-2 rounded-lg hover:bg-indigo-700&quot;
  &gt;
    查看詳情
  &lt;/a&gt;
&lt;/div&gt;
```

使用——就像 HTML 標籤：

```html
@foreach($groupBuys as $groupBuy)
&lt;x-group-buy-card :group-buy=&quot;$groupBuy&quot; /&gt;
@endforeach
```

`:group-buy=&quot;$groupBuy&quot;` 的冒號前綴表示「這是一個 PHP 表達式」，不加冒號就是純字串。

### 匿名 Component（不需要 PHP class）

如果 Component 只有模板沒有邏輯，可以只建 Blade 檔案：

```html
&lt;!-- resources/views/components/badge.blade.php --&gt;
@props([&apos;color&apos; =&gt; &apos;gray&apos;, &apos;label&apos;])

&lt;span class=&quot;px-2 py-1 rounded text-sm bg-{{ $color }}-100 text-{{ $color }}-700&quot;&gt;
  {{ $label }}
&lt;/span&gt;
```

```html
&lt;x-badge color=&quot;green&quot; label=&quot;開團中&quot; /&gt; &lt;x-badge color=&quot;red&quot; label=&quot;已截止&quot; /&gt;
```

`@props` 宣告這個 Component 接受哪些屬性，以及預設值。

## Livewire 3：用 PHP 寫前端互動

到目前為止，我們的頁面還是傳統的 server-side rendering——使用者每次操作都要整頁重新載入。Livewire 改變了這件事：它讓你**用 PHP 寫有狀態的元件**，使用者互動時只重新渲染有變化的部分。

### 安裝 Livewire

```bash
composer require livewire/livewire
```

就這樣。Livewire 會自動注入需要的 JavaScript（透過 `@livewireStyles` 和 `@livewireScripts`，但如果你用 `@vite` 它會自動處理）。

### Livewire 的運作原理

```
使用者輸入/點擊
    ↓
Livewire JS 攔截事件
    ↓
送 AJAX 到 server，帶上 component 狀態
    ↓
Server 端 PHP 處理邏輯、更新 state
    ↓
Server 回傳 HTML diff
    ↓
Livewire JS 局部更新 DOM
```

對使用者來說，體驗就像 SPA；對開發者來說，你只寫 PHP。

&gt; **React/Vue 開發者的理解方式：** Livewire component ≈ React component，`public` 屬性 ≈ `state`，PHP 方法 ≈ event handler。差別是 state 存在 server，不在 client。

### 建立第一個 Livewire Component

```bash
php artisan make:livewire Counter
```

產生兩個檔案：

```php
// app/Livewire/Counter.php
&lt;?php

namespace App\Livewire;

use Livewire\Component;

class Counter extends Component
{
    public int $count = 0;  // 狀態（state）

    public function increment(): void
    {
        $this-&gt;count++;
    }

    public function decrement(): void
    {
        $this-&gt;count--;
    }

    public function render()
    {
        return view(&apos;livewire.counter&apos;);
    }
}
```

```html
&lt;!-- resources/views/livewire/counter.blade.php --&gt;
&lt;div&gt;
  &lt;h2 class=&quot;text-2xl font-bold&quot;&gt;{{ $count }}&lt;/h2&gt;
  &lt;div class=&quot;space-x-2 mt-2&quot;&gt;
    &lt;button wire:click=&quot;decrement&quot; class=&quot;px-4 py-2 bg-red-500 text-white rounded&quot;&gt;-&lt;/button&gt;
    &lt;button wire:click=&quot;increment&quot; class=&quot;px-4 py-2 bg-green-500 text-white rounded&quot;&gt;+&lt;/button&gt;
  &lt;/div&gt;
&lt;/div&gt;
```

在任何 Blade 頁面裡嵌入：

```html
&lt;livewire:counter /&gt;
```

點按鈕，數字會即時更新——沒寫任何 JavaScript。

## wire:model、wire:click——最常用的指令

Livewire 的指令（directive）讓你把使用者互動綁定到 PHP 方法和屬性。

### wire:click

把點擊事件綁定到 PHP 方法：

```html
&lt;button wire:click=&quot;addToCart({{ $product-&gt;id }})&quot;&gt;加入購物車&lt;/button&gt;
```

```php
public function addToCart(int $productId): void
{
    // PHP 邏輯：加入購物車
}
```

### wire:model

雙向資料綁定——輸入框的值同步到 PHP 屬性：

```html
&lt;!-- 即時同步（每次按鍵都觸發） --&gt;
&lt;input wire:model.live=&quot;search&quot; type=&quot;text&quot; placeholder=&quot;搜尋團購...&quot; /&gt;

&lt;!-- 離開輸入框才同步（預設行為） --&gt;
&lt;input wire:model.blur=&quot;email&quot; type=&quot;email&quot; /&gt;

&lt;!-- 表單送出才同步 --&gt;
&lt;input wire:model=&quot;name&quot; type=&quot;text&quot; /&gt;
```

```php
public string $search = &apos;&apos;;

// 每次 $search 改變，render() 就會重新執行
// 搭配下面的 render，就能實現即時搜尋
public function render()
{
    return view(&apos;livewire.group-buy-list&apos;, [
        &apos;groupBuys&apos; =&gt; GroupBuy::open()
            -&gt;when($this-&gt;search, fn($q) =&gt; $q-&gt;where(&apos;title&apos;, &apos;like&apos;, &quot;%{$this-&gt;search}%&quot;))
            -&gt;latest()
            -&gt;paginate(12),
    ]);
}
```

&gt; **`wire:model.live` vs `wire:model`：** `.live` 修飾符讓每次按鍵都觸發同步（適合即時搜尋），沒有修飾符的要等表單 submit。

&gt; **先講一個我希望早點有人提醒我的代價：** Livewire 的即時搜尋很爽，但它跟 React/Vue 那種前端即時過濾完全不是同一回事。client-side 框架打字時是在瀏覽器記憶體裡 filter 陣列，零後端負擔；Livewire 是每按一個鍵 → 發一個 AJAX request → server 跑一次帶 `like &quot;%keyword%&quot;` 的查詢。所以 `.debounce.300ms` 我不會叫它「優化」，它是必要的防線——沒加的話，使用者打「衛生紙」三個字你的 server 就吃了好幾發查詢，幾十個人同時搜你就知道痛。同理，分頁大小（這裡 `paginate(12)`）、`search` 欄位有沒有索引，都是成本問題不是有空再說的事。而且要老實講：`like &quot;%keyword%&quot;` 開頭那個 `%` 會讓 MySQL 索引直接失效、只能全表掃，資料量小無感，幾十萬筆以上就該認真考慮 Laravel Scout + Meilisearch 之類的全文檢索了。寫起來只是一個 PHP 檔案，但它背後是真的在打 DB，這點別忘。

### wire:submit

攔截表單提交：

```html
&lt;form wire:submit=&quot;save&quot;&gt;
  &lt;input wire:model=&quot;title&quot; type=&quot;text&quot; /&gt;
  &lt;input wire:model=&quot;price&quot; type=&quot;number&quot; /&gt;
  &lt;button type=&quot;submit&quot;&gt;儲存&lt;/button&gt;
&lt;/form&gt;
```

```php
public string $title = &apos;&apos;;
public int $price = 0;

public function save(): void
{
    $this-&gt;validate([
        &apos;title&apos; =&gt; &apos;required|min:3&apos;,
        &apos;price&apos; =&gt; &apos;required|integer|min:100&apos;,
    ]);

    GroupBuy::create([
        &apos;title&apos; =&gt; $this-&gt;title,
        &apos;price_per_unit&apos; =&gt; $this-&gt;price,
        // ...
    ]);

    $this-&gt;redirect(&apos;/group-buys&apos;);
}
```

### wire:loading

顯示載入狀態：

```html
&lt;button wire:click=&quot;save&quot;&gt;
  &lt;span wire:loading.remove&gt;儲存&lt;/span&gt;
  &lt;span wire:loading&gt;處理中...&lt;/span&gt;
&lt;/button&gt;

&lt;!-- 整個區塊顯示 loading overlay --&gt;
&lt;div wire:loading.class=&quot;opacity-50 pointer-events-none&quot;&gt;{{-- 內容 --}}&lt;/div&gt;
```

### 常用指令速查

| 指令                     | 用途                | React 類比                         |
| ------------------------ | ------------------- | ---------------------------------- |
| `wire:click=&quot;method&quot;`    | 點擊觸發 PHP 方法   | `onClick={handler}`                |
| `wire:model.live=&quot;prop&quot;` | 即時雙向綁定        | `value={state} onChange={set}`     |
| `wire:submit=&quot;method&quot;`   | 表單提交            | `onSubmit={handler}`               |
| `wire:loading`           | 載入狀態顯示/隱藏   | loading state + conditional render |
| `wire:confirm=&quot;確定？&quot;`  | 確認對話框          | `if (confirm(...))`                |
| `wire:poll.5s`           | 每 5 秒自動重新渲染 | `useEffect` + `setInterval`        |
| `wire:key=&quot;unique&quot;`      | 列表項目的 key      | `key={id}`                         |

## Volt：單檔案 Livewire Component

Livewire 的標準做法是 PHP class + Blade 模板兩個檔案。**Volt** 讓你把兩者合在一個 `.blade.php` 裡——類似 Vue 的 SFC（Single File Component）。

&gt; **⚠️ Livewire 4 版本注意（2026-01-15 釋出）**：Livewire 4 已將單檔元件（SFC）**內建進核心**，不再需要單獨安裝 Volt 套件。`php artisan make:livewire` 預設即產生單檔格式，命名空間也從 `Livewire\Volt\Component` 改為 `Livewire\Component`。若你使用的是 **Livewire 3**，繼續依下方指令安裝 Volt；若已升級至 **Livewire 4**，直接用 `make:livewire` 即可，Volt 套件可移除。

```bash
# Livewire 3 才需要：
composer require livewire/volt
php artisan volt:install
```

建立一個 Volt component：

```html
&lt;!-- resources/views/livewire/participant-counter.blade.php --&gt;
&lt;?php
use App\Models\GroupBuy;
use Livewire\Volt\Component;

new class extends Component {
    public GroupBuy $groupBuy;
    public int $count;

    public function mount(GroupBuy $groupBuy): void
    {
        $this-&gt;groupBuy = $groupBuy;
        $this-&gt;count = $groupBuy-&gt;participants()-&gt;count();
    }

    public function refresh(): void
    {
        $this-&gt;count = $this-&gt;groupBuy-&gt;participants()-&gt;count();
    }
}; ?&gt;

&lt;div wire:poll.10s=&quot;refresh&quot; class=&quot;flex items-center gap-2&quot;&gt;
  &lt;span class=&quot;text-2xl font-bold&quot;&gt;{{ $count }}&lt;/span&gt;
  &lt;span class=&quot;text-gray-500&quot;&gt;人已跟團&lt;/span&gt;
&lt;/div&gt;
```

PHP 邏輯和 HTML 模板在同一個檔案裡——適合小型、單一職責的元件。

&gt; **什麼時候用 Volt？** 小元件（計數器、狀態切換、簡單表單）用 Volt 很方便。複雜元件（多步驟表單、帶分頁的列表）還是拆成兩個檔案比較好維護。

## Alpine.js：Livewire 的最佳搭檔

有些互動是純前端的：下拉選單展開/收合、Modal 彈出/關閉、Tab 切換。這些不需要跑到 server，用 Alpine.js 就好。

Alpine.js 隨 Livewire 3 自動安裝，不用額外設定。

### 基本語法

```html
&lt;!-- 下拉選單 --&gt;
&lt;div x-data=&quot;{ open: false }&quot;&gt;
  &lt;button @click=&quot;open = !open&quot;&gt;選單&lt;/button&gt;
  &lt;ul x-show=&quot;open&quot; @click.away=&quot;open = false&quot; x-transition&gt;
    &lt;li&gt;&lt;a href=&quot;/profile&quot;&gt;個人資料&lt;/a&gt;&lt;/li&gt;
    &lt;li&gt;&lt;a href=&quot;/settings&quot;&gt;設定&lt;/a&gt;&lt;/li&gt;
    &lt;li&gt;&lt;a href=&quot;/logout&quot;&gt;登出&lt;/a&gt;&lt;/li&gt;
  &lt;/ul&gt;
&lt;/div&gt;

&lt;!-- Modal --&gt;
&lt;div x-data=&quot;{ showModal: false }&quot;&gt;
  &lt;button @click=&quot;showModal = true&quot;&gt;開團規則&lt;/button&gt;

  &lt;div
    x-show=&quot;showModal&quot;
    x-transition.opacity
    class=&quot;fixed inset-0 bg-black/50 flex items-center justify-center&quot;
  &gt;
    &lt;div class=&quot;bg-white rounded-xl p-6 max-w-md&quot; @click.away=&quot;showModal = false&quot;&gt;
      &lt;h3 class=&quot;font-bold text-lg&quot;&gt;開團規則&lt;/h3&gt;
      &lt;p class=&quot;mt-2 text-gray-600&quot;&gt;最低 3 人成團，截止時間前未達人數自動取消。&lt;/p&gt;
      &lt;button @click=&quot;showModal = false&quot; class=&quot;mt-4 px-4 py-2 bg-gray-200 rounded&quot;&gt;關閉&lt;/button&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;
```

### Alpine.js vs Livewire 分工

| 場景             | 用誰                 | 原因                             |
| ---------------- | -------------------- | -------------------------------- |
| 即時搜尋、篩選   | Livewire             | 需要查資料庫                     |
| 表單提交、CRUD   | Livewire             | 需要 server 處理                 |
| 下拉選單、Modal  | Alpine.js            | 純 UI 狀態，不需要 server        |
| Tab 切換         | Alpine.js            | 純前端                           |
| 計時器、動畫     | Alpine.js            | 需要毫秒級響應                   |
| 購物車數量 badge | Livewire + Alpine.js | Livewire 更新數據，Alpine 做動畫 |

簡單記：**需要資料庫或 PHP 邏輯 → Livewire；純 UI 互動 → Alpine.js**。

## Tailwind CSS 整合：快速美化介面

Laravel 12 新建專案預設就有 Tailwind CSS 的設定。如果你從其他框架來可能已經用過——它是 utility-first 的 CSS 框架，直接在 HTML 上加 class 來寫樣式。

```bash
# 安裝前端依賴
npm install

# 啟動 Vite dev server（編譯 CSS 和 JS）
npm run dev
```

確保 Layout 裡有引入 Vite：

```html
&lt;head&gt;
  @vite([&apos;resources/css/app.css&apos;, &apos;resources/js/app.js&apos;])
&lt;/head&gt;
```

### 常用 Tailwind 速查

```html
&lt;!-- 容器和間距 --&gt;
&lt;div class=&quot;max-w-5xl mx-auto px-6 py-8&quot;&gt;
  &lt;!-- Grid 佈局 --&gt;
  &lt;div class=&quot;grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6&quot;&gt;
    &lt;!-- 卡片 --&gt;
    &lt;div class=&quot;bg-white rounded-xl shadow-sm border p-5 hover:shadow-md transition&quot;&gt;
      &lt;!-- 按鈕 --&gt;
      &lt;button class=&quot;bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition&quot;&gt;
        &lt;!-- 文字 --&gt;
        &lt;h1 class=&quot;text-2xl font-bold text-gray-900&quot;&gt;
          &lt;p class=&quot;text-sm text-gray-500 mt-2&quot;&gt;
            &lt;!-- Badge --&gt;
            &lt;span class=&quot;bg-green-100 text-green-700 text-sm px-2 py-1 rounded&quot;&gt;
              &lt;!-- Responsive --&gt;
              &lt;div class=&quot;text-sm md:text-base lg:text-lg&quot;&gt;&lt;/div
            &gt;&lt;/span&gt;
          &lt;/p&gt;
        &lt;/h1&gt;
      &lt;/button&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;
```

&gt; **不習慣 utility class？** 一開始覺得 HTML 很亂是正常的。但搭配 Blade Component，你把樣式封裝在 Component 裡，使用端看到的就是乾淨的 `&lt;x-group-buy-card :group-buy=&quot;$gb&quot; /&gt;`。

## 實作：揪好買團購列表與即時搜尋

把所有東西串起來——用 Livewire 做一個有即時搜尋和分類篩選的團購列表頁。

### Step 1：建立 Livewire Component

```bash
php artisan make:livewire GroupBuyList
```

`app/Livewire/GroupBuyList.php`：

```php
&lt;?php

namespace App\Livewire;

use App\Models\GroupBuy;
use Livewire\Component;
use Livewire\WithPagination;

class GroupBuyList extends Component
{
    use WithPagination;

    public string $search = &apos;&apos;;
    public string $status = &apos;&apos;;
    public string $sortBy = &apos;latest&apos;;

    // 搜尋條件改變時重置頁碼
    public function updatedSearch(): void
    {
        $this-&gt;resetPage();
    }

    public function updatedStatus(): void
    {
        $this-&gt;resetPage();
    }

    public function render()
    {
        $groupBuys = GroupBuy::query()
            -&gt;with(&apos;organizer&apos;)            // Eager loading 避免 N+1
            -&gt;withCount(&apos;participants&apos;)     // 載入參與者數量
            -&gt;when($this-&gt;search, fn($q) =&gt;
                $q-&gt;where(&apos;title&apos;, &apos;like&apos;, &quot;%{$this-&gt;search}%&quot;)
                  -&gt;orWhere(&apos;product_name&apos;, &apos;like&apos;, &quot;%{$this-&gt;search}%&quot;)
            )
            -&gt;when($this-&gt;status === &apos;open&apos;, fn($q) =&gt;
                $q-&gt;where(&apos;status&apos;, &apos;open&apos;)-&gt;where(&apos;deadline&apos;, &apos;&gt;&apos;, now())
            )
            -&gt;when($this-&gt;status === &apos;confirmed&apos;, fn($q) =&gt;
                $q-&gt;where(&apos;status&apos;, &apos;confirmed&apos;)
            )
            -&gt;when($this-&gt;sortBy === &apos;latest&apos;, fn($q) =&gt; $q-&gt;latest())
            -&gt;when($this-&gt;sortBy === &apos;deadline&apos;, fn($q) =&gt; $q-&gt;orderBy(&apos;deadline&apos;))
            -&gt;when($this-&gt;sortBy === &apos;popular&apos;, fn($q) =&gt; $q-&gt;orderByDesc(&apos;participants_count&apos;))
            -&gt;paginate(12);

        return view(&apos;livewire.group-buy-list&apos;, [
            &apos;groupBuys&apos; =&gt; $groupBuys,
        ]);
    }
}
```

### Step 2：Livewire Blade 模板

`resources/views/livewire/group-buy-list.blade.php`：

```html
&lt;div&gt;
  {{-- 搜尋與篩選列 --}}
  &lt;div class=&quot;flex flex-col md:flex-row gap-4 mb-8&quot;&gt;
    &lt;div class=&quot;flex-1&quot;&gt;
      &lt;input
        wire:model.live.debounce.300ms=&quot;search&quot;
        type=&quot;text&quot;
        placeholder=&quot;🔍 搜尋團購名稱...&quot;
        class=&quot;w-full px-4 py-3 rounded-lg border focus:ring-2 focus:ring-indigo-500 focus:border-transparent&quot;
      /&gt;
    &lt;/div&gt;

    &lt;select wire:model.live=&quot;status&quot; class=&quot;px-4 py-3 rounded-lg border&quot;&gt;
      &lt;option value=&quot;&quot;&gt;全部狀態&lt;/option&gt;
      &lt;option value=&quot;open&quot;&gt;開團中&lt;/option&gt;
      &lt;option value=&quot;confirmed&quot;&gt;已成團&lt;/option&gt;
    &lt;/select&gt;

    &lt;select wire:model.live=&quot;sortBy&quot; class=&quot;px-4 py-3 rounded-lg border&quot;&gt;
      &lt;option value=&quot;latest&quot;&gt;最新&lt;/option&gt;
      &lt;option value=&quot;deadline&quot;&gt;即將截止&lt;/option&gt;
      &lt;option value=&quot;popular&quot;&gt;最多人跟&lt;/option&gt;
    &lt;/select&gt;
  &lt;/div&gt;

  {{-- 團購卡片 Grid --}}
  &lt;div
    wire:loading.class=&quot;opacity-50&quot;
    class=&quot;grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 transition&quot;
  &gt;
    @forelse($groupBuys as $groupBuy)
    &lt;x-group-buy-card :group-buy=&quot;$groupBuy&quot; wire:key=&quot;gb-{{ $groupBuy-&gt;id }}&quot; /&gt;
    @empty
    &lt;div class=&quot;col-span-full text-center py-12 text-gray-400&quot;&gt;
      &lt;p class=&quot;text-4xl mb-2&quot;&gt;🍃&lt;/p&gt;
      &lt;p&gt;找不到符合條件的團購&lt;/p&gt;
    &lt;/div&gt;
    @endforelse
  &lt;/div&gt;

  {{-- 分頁 --}}
  &lt;div class=&quot;mt-8&quot;&gt;{{ $groupBuys-&gt;links() }}&lt;/div&gt;
&lt;/div&gt;
```

### Step 3：頁面路由與 View

`routes/web.php`：

```php
Route::get(&apos;/group-buys&apos;, function () {
    return view(&apos;group-buys.index&apos;);
});
```

`resources/views/group-buys/index.blade.php`：

```html
&lt;x-layouts.app title=&quot;所有團購&quot;&gt;
  &lt;div class=&quot;flex justify-between items-center mb-6&quot;&gt;
    &lt;h1 class=&quot;text-2xl font-bold&quot;&gt;🛒 所有團購&lt;/h1&gt;
    @auth
    &lt;a
      href=&quot;/group-buys/create&quot;
      class=&quot;bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700&quot;
    &gt;
      + 我要開團
    &lt;/a&gt;
    @endauth
  &lt;/div&gt;

  &lt;livewire:group-buy-list /&gt;
&lt;/x-layouts.app&gt;
```

### Step 4：看看效果

```bash
# 確保有測試資料
php artisan migrate:fresh --seed

# 啟動兩個終端
php artisan serve    # 後端
npm run dev          # 前端（Vite）
```

打開 `http://localhost:8000/group-buys`，你會看到：

- **搜尋框**——輸入文字後 300ms 自動篩選（`debounce`），不用按 Enter
- **狀態篩選**——切換「開團中」「已成團」即時過濾
- **排序**——最新、即將截止、最多人跟
- **分頁**——超過 12 筆自動分頁
- **Loading 狀態**——篩選時卡片區域半透明

完全沒寫 JavaScript。所有邏輯都在 `GroupBuyList.php` 這一個 PHP 檔案裡。

### 效能注意：wire:key

注意每個列表項目的 `wire:key=&quot;gb-{{ $groupBuy-&gt;id }}&quot;`。這跟 React 的 `key` prop 一樣——幫助 Livewire 的 DOM diffing 算法正確辨識哪些項目被新增/移除/移動。列表裡沒加 `wire:key` 會導致奇怪的渲染 bug。

## 小結：全 PHP 技術棧的前端開發

這一章我們走過了 Laravel 前端開發的完整工具鏈：

- **Blade Component**——用 `&lt;x-component&gt;` 語法建立可重用的 UI 積木，比 `@include` 更好組合
- **Livewire 3**——用 PHP 寫有狀態的前端元件，`wire:model`、`wire:click` 處理使用者互動
- **Volt**——單檔案 Livewire Component，適合小型元件
- **Alpine.js**——純前端互動（下拉選單、Modal、Tab），不需要跑到 server
- **Tailwind CSS**——utility-first CSS，搭配 Blade Component 封裝樣式

我們也為揪好買做了第一個互動頁面：

- **團購列表**——Livewire 即時搜尋 + 篩選 + 排序 + 分頁
- **GroupBuyCard Component**——可重用的團購卡片 UI

**Livewire vs React/Vue/Svelte 的取捨：**

|          | Livewire                | React/Vue/Svelte       |
| -------- | ----------------------- | ---------------------- |
| 學習曲線 | 低（只寫 PHP）          | 高（需學 JS 框架）     |
| 初始載入 | 快（server render）     | 慢（需載入 JS bundle） |
| 互動延遲 | 略高（每次都跑 server） | 低（client-side）      |
| SEO      | 天生友好                | 需要 SSR               |
| 適合場景 | 內容型、CRUD 為主       | 高互動、即時協作       |

對揪好買這種團購平台來說，Livewire 完全夠用。如果未來需要更豐富的即時互動（例如拖拉排序、即時聊天），第十一章的 [API + Sanctum](/blog/laravel-guide-api-sanctum-rest/) 可以讓你銜接任何前端框架。

下一章，我們要讓揪好買的使用者能夠註冊和登入——[認證與授權系統](/blog/laravel-guide-auth-breeze-authorization/)。用 Laravel 12 的新版 Starter Kit 十分鐘搞定。</content:encoded><media:content url="https://bobochen.dev/_astro/cover.CqNn7Wq0.webp" medium="image"/><category>PHP</category><category>Laravel</category><category>Blade</category><category>Livewire</category><category>Alpine.js</category><category>Tailwind CSS</category><enclosure url="https://bobochen.dev/_astro/cover.CqNn7Wq0.webp" length="0" type="image/png"/></item><item><title>Eloquent ORM：不寫 SQL 也能操作資料庫的 Laravel 之道</title><link>https://bobochen.dev/blog/laravel-guide-eloquent-orm-models/</link><guid isPermaLink="true">https://bobochen.dev/blog/laravel-guide-eloquent-orm-models/</guid><description>Laravel Eloquent ORM 完整教學：從 Migration 建表、Model 命名慣例、CRUD 操作，到一對多／多對多關聯與 Factory／Seeder 測試資料，不寫 SQL 也能優雅操作資料庫，一次搞懂。</description><pubDate>Tue, 25 Mar 2025 00:00:00 GMT</pubDate><content:encoded>每個 Web 應用程式的核心都是資料。使用者註冊帳號、建立團購、加入訂單——這些動作最終都要寫進資料庫裡。傳統做法是手寫 SQL，但 SQL 散落在程式碼各處會讓維護變成噩夢。Laravel 的解法叫做 **Eloquent ORM**：讓你用優雅的 PHP 語法操作資料庫，每張資料表對應一個 Model 類別，每一列資料就是一個物件。

Eloquent 的強大不只在於少寫 SQL。它把資料表之間的關聯（一對多、多對多）變成物件屬性，讓你用 `$user-&gt;orders` 就能拿到某個使用者的所有訂單，完全不需要手寫 JOIN。搭配 Migration（用程式碼管理資料表結構）和 Factory/Seeder（自動產生測試資料），你的資料層從開發到測試都有完整的工具鏈支撐。

這一章我們要為揪好買設計完整的資料結構：使用者、團購、商品、參與者。你會學到 Migration 怎麼寫、Model 怎麼定義、關聯怎麼設定、CRUD 怎麼操作。讀完之後，揪好買的資料骨架就搭好了，後面的章節只需要在上面蓋 UI 和業務邏輯。

## 開始用 Eloquent ORM 前：資料庫設定（MySQL / SQLite）

[上一章](/blog/laravel-guide-setup-first-route/)提過，Laravel 12 預設用 SQLite——零設定、開箱即用。打開 `.env` 看看：

&gt; **版本說明**：本系列以 Laravel 12 為基準撰寫。Laravel 13 已於 2026 年 3 月 17 日正式發布（最低需求 PHP 8.3），是目前的最新主版本；其升級路徑幾乎沒有破壞性變更，本文的 Eloquent、Migration 範例同樣適用 Laravel 13，直接照做即可。

```bash
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
```

SQLite 的資料庫就是一個檔案 `database/database.sqlite`，開發階段用它非常方便。

如果你想用 MySQL 或 PostgreSQL，改一下 `.env`：

```bash
# MySQL
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=jiu_hao_mai
DB_USERNAME=root
DB_PASSWORD=secret

# PostgreSQL
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=jiu_hao_mai
DB_USERNAME=postgres
DB_PASSWORD=secret
```

改完之後清一下 config 快取：

```bash
php artisan config:clear
```

&gt; **建議：** 開發階段用 SQLite 就好，省掉裝 MySQL 的麻煩。等到部署正式環境再切換——Eloquent 的程式碼完全不用改，只動 `.env` 設定。

### 跨 ORM 對照

| 概念       | Laravel (Eloquent)          | Django (Python)             | Sequelize (Node.js)          | Prisma (Node.js)                     |
| ---------- | --------------------------- | --------------------------- | ---------------------------- | ------------------------------------ |
| Model 定義 | PHP class                   | Python class                | JS class / define            | Schema file                          |
| Migration  | PHP class                   | Python file                 | JS file                      | Schema + migrate                     |
| 查詢語法   | `User::where(...)`          | `User.objects.filter(...)`  | `User.findAll({where: ...})` | `prisma.user.findMany({where: ...})` |
| 關聯       | `hasMany()` / `belongsTo()` | `ForeignKey` / `ManyToMany` | `hasMany` / `belongsTo`      | `@relation`                          |

## Migration：用程式碼管理資料表結構

Migration 就是資料表的版本控制——用程式碼定義資料表結構，團隊裡每個人跑一次 `migrate` 就能得到一模一樣的資料庫。不用再傳 SQL dump，也不用手動對 schema。

### 建立 Migration

```bash
php artisan make:migration create_products_table
```

這會在 `database/migrations/` 產生一個帶時間戳的檔案：

```php
&lt;?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create(&apos;products&apos;, function (Blueprint $table) {
            $table-&gt;id();                          // bigint unsigned auto increment PK
            $table-&gt;string(&apos;name&apos;);                // varchar(255)
            $table-&gt;text(&apos;description&apos;)-&gt;nullable(); // text, 可為 null
            $table-&gt;integer(&apos;price&apos;);              // int（存整數，以「分」為單位避免浮點數問題）
            $table-&gt;string(&apos;image&apos;)-&gt;nullable();   // 商品圖片路徑
            $table-&gt;boolean(&apos;is_active&apos;)-&gt;default(true);
            $table-&gt;timestamps();                  // created_at + updated_at
        });
    }

    public function down(): void
    {
        Schema::dropIfExists(&apos;products&apos;);
    }
};
```

`up()` 定義「建立時做什麼」，`down()` 定義「回滾時做什麼」。

### 常用欄位型別

| 方法                               | SQL 型別                  | 用途       |
| ---------------------------------- | ------------------------- | ---------- |
| `$table-&gt;id()`                     | `BIGINT UNSIGNED AI PK`   | 主鍵       |
| `$table-&gt;string(&apos;name&apos;)`           | `VARCHAR(255)`            | 短字串     |
| `$table-&gt;string(&apos;code&apos;, 10)`       | `VARCHAR(10)`             | 指定長度   |
| `$table-&gt;text(&apos;body&apos;)`             | `TEXT`                    | 長文字     |
| `$table-&gt;integer(&apos;qty&apos;)`           | `INT`                     | 整數       |
| `$table-&gt;unsignedInteger(&apos;qty&apos;)`   | `INT UNSIGNED`            | 非負整數   |
| `$table-&gt;decimal(&apos;price&apos;, 10, 2)`  | `DECIMAL(10,2)`           | 精確小數   |
| `$table-&gt;boolean(&apos;active&apos;)`        | `TINYINT(1)`              | 布林值     |
| `$table-&gt;date(&apos;birth_date&apos;)`       | `DATE`                    | 日期       |
| `$table-&gt;dateTime(&apos;confirmed_at&apos;)` | `DATETIME`                | 日期時間   |
| `$table-&gt;timestamp(&apos;verified_at&apos;)` | `TIMESTAMP`               | 時間戳     |
| `$table-&gt;timestamps()`             | `created_at + updated_at` | 自動時間戳 |
| `$table-&gt;softDeletes()`            | `deleted_at`              | 軟刪除     |
| `$table-&gt;json(&apos;metadata&apos;)`         | `JSON`                    | JSON 欄位  |
| `$table-&gt;foreignId(&apos;user_id&apos;)`     | `BIGINT UNSIGNED`         | 外鍵       |

### 修飾方法

```php
$table-&gt;string(&apos;email&apos;)-&gt;unique();              // 唯一
$table-&gt;string(&apos;nickname&apos;)-&gt;nullable();         // 可為 null
$table-&gt;integer(&apos;stock&apos;)-&gt;default(0);           // 預設值
$table-&gt;foreignId(&apos;user_id&apos;)-&gt;constrained();    // 外鍵 + 約束
$table-&gt;foreignId(&apos;user_id&apos;)
      -&gt;constrained()
      -&gt;cascadeOnDelete();                      // 刪除時連動刪除
$table-&gt;index(&apos;email&apos;);                         // 加索引
```

### 執行與回滾

```bash
# 執行所有未跑過的 migration
php artisan migrate

# 回滾上一次的 migration
php artisan migrate:rollback

# 回滾所有 migration 再重新執行（開發用，會清空資料）
php artisan migrate:fresh

# 回滾再重跑，順便跑 Seeder
php artisan migrate:fresh --seed

# 查看 migration 狀態
php artisan migrate:status
```

&gt; **金錢欄位的建議：** 永遠用整數存錢（以「分」為單位），不要用 `float`。`$70.50` 存成 `7050`，顯示時再除以 100。這樣可以避免浮點數精度問題——0.1 + 0.2 ≠ 0.3 在任何語言都是這樣。（`decimal`/`DECIMAL` 在 SQL 層其實是精確的，但資料讀進 PHP 後仍可能被轉回浮點數，整數方案直接從源頭迴避這個風險。）

## Eloquent Model：每張表都有一個代言人

每張資料表對應一個 Eloquent Model。Model 是你跟資料庫互動的唯一介面——不用寫 SQL，用 PHP 物件的方式操作資料。

### 建立 Model

```bash
# 只建 Model
php artisan make:model Product

# 一次建 Model + Migration + Factory + Seeder
php artisan make:model Product -mfs
```

```php
&lt;?php

// app/Models/Product.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Product extends Model
{
    // 就這樣，空的就能用了
}
```

### 命名慣例

Eloquent 靠**命名慣例**自動推斷對應的資料表：

| Model 類別  | 自動對應的資料表 |
| ----------- | ---------------- |
| `Product`   | `products`       |
| `User`      | `users`          |
| `GroupBuy`  | `group_buys`     |
| `OrderItem` | `order_items`    |

規則：**Model 用 PascalCase 單數，資料表用 snake_case 複數**。如果你遵循這個慣例，完全不需要額外設定。

如果你有特殊需求（例如接手舊系統的奇怪表名），可以覆寫：

```php
class Product extends Model
{
    protected $table = &apos;my_weird_products_table&apos;;  // 自訂表名
    protected $primaryKey = &apos;product_id&apos;;           // 自訂主鍵
    public $timestamps = false;                     // 不自動維護時間戳
}
```

## CRUD 操作：建立、讀取、更新、刪除

### Create（建立）

```php
// 方法一：new + save
$product = new Product();
$product-&gt;name = &apos;辦公室零食箱&apos;;
$product-&gt;price = 59900;  // $599.00
$product-&gt;save();

// 方法二：create（mass assignment，需設定 $fillable）
$product = Product::create([
    &apos;name&apos; =&gt; &apos;辦公室零食箱&apos;,
    &apos;price&apos; =&gt; 59900,
    &apos;description&apos; =&gt; &apos;精選 10 款台灣經典零食，滿足整層辦公室&apos;,
]);
```

### Read（讀取）

```php
// 取得所有商品
$products = Product::all();

// 用主鍵查詢（找不到回傳 null）
$product = Product::find(1);

// 用主鍵查詢（找不到拋 404 例外——Controller 裡很好用）
$product = Product::findOrFail(1);

// 條件查詢
$activeProducts = Product::where(&apos;is_active&apos;, true)
    -&gt;where(&apos;price&apos;, &apos;&lt;&apos;, 100000)
    -&gt;orderBy(&apos;created_at&apos;, &apos;desc&apos;)
    -&gt;get();

// 取第一筆
$cheapest = Product::where(&apos;is_active&apos;, true)
    -&gt;orderBy(&apos;price&apos;)
    -&gt;first();

// 計數
$count = Product::where(&apos;is_active&apos;, true)-&gt;count();

// 分頁（每頁 20 筆）
$products = Product::where(&apos;is_active&apos;, true)
    -&gt;latest()    // orderBy(&apos;created_at&apos;, &apos;desc&apos;) 的語法糖
    -&gt;paginate(20);
```

### Update（更新）

```php
// 方法一：find + 修改 + save
$product = Product::find(1);
$product-&gt;price = 49900;
$product-&gt;save();

// 方法二：update（批次更新）
Product::where(&apos;is_active&apos;, false)
    -&gt;update([&apos;is_active&apos; =&gt; true]);
```

### Delete（刪除）

```php
// 方法一：find + delete
$product = Product::find(1);
$product-&gt;delete();

// 方法二：destroy（用主鍵刪除）
Product::destroy(1);
Product::destroy([1, 2, 3]);

// 方法三：條件刪除
Product::where(&apos;is_active&apos;, false)-&gt;delete();
```

### 在 Tinker 裡試試看

```bash
php artisan tinker
```

```php
&gt;&gt;&gt; Product::create([&apos;name&apos; =&gt; &apos;手工鳳梨酥&apos;, &apos;price&apos; =&gt; 35000]);
&gt;&gt;&gt; Product::all();
&gt;&gt;&gt; Product::where(&apos;price&apos;, &apos;&gt;&apos;, 30000)-&gt;get();
&gt;&gt;&gt; Product::find(1)-&gt;update([&apos;price&apos; =&gt; 32000]);
```

Tinker 是你學 Eloquent 最好的朋友——不用寫 Controller 和路由，直接在 REPL 裡操作資料庫。

## Mass Assignment 防護：保護你的資料

如果你直接用 `Product::create($request-&gt;all())` 把使用者的整個表單資料丟進去，攻擊者可以偷偷夾帶 `is_admin=1` 之類的欄位——這叫做 **Mass Assignment 攻擊**。

Laravel 的防護機制：預設情況下，所有欄位都**不允許**被批次賦值。你必須明確指定哪些欄位可以被填入：

### $fillable（白名單）

```php
class Product extends Model
{
    // 只有這些欄位可以被 create() 和 update() 批次賦值
    protected $fillable = [
        &apos;name&apos;,
        &apos;description&apos;,
        &apos;price&apos;,
        &apos;image&apos;,
        &apos;is_active&apos;,
    ];
}
```

### $guarded（黑名單）

```php
class Product extends Model
{
    // 除了這些，其他都可以被批次賦值
    protected $guarded = [&apos;id&apos;];
}
```

&gt; **慣例：** 大多數 Laravel 開發者用 `$fillable`（白名單），因為更安全——新增欄位時，你必須刻意把它加到 `$fillable` 才能被批次賦值。用 `$guarded` 的話，新欄位預設就是開放的，比較容易出事。

### 跨框架對照

| 框架    | 防護機制                       | 做法                                     |
| ------- | ------------------------------ | ---------------------------------------- |
| Laravel | `$fillable` / `$guarded`       | 在 Model 裡定義                          |
| Django  | 不需要（form 有自己的 fields） | Form class 定義可寫欄位                  |
| Rails   | `Strong Parameters`            | 在 Controller 裡 `permit(:name, :price)` |

## Relationships：一對多、多對多

關聯是 Eloquent 最精華的部分。用一行方法定義，就能優雅地存取相關資料。

### 一對多（hasMany / belongsTo）

一個使用者可以建立多個團購：

```php
// User Model
class User extends Authenticatable
{
    public function groupBuys(): HasMany
    {
        return $this-&gt;hasMany(GroupBuy::class);
    }
}

// GroupBuy Model
class GroupBuy extends Model
{
    public function organizer(): BelongsTo
    {
        return $this-&gt;belongsTo(User::class, &apos;user_id&apos;);
    }
}
```

使用方式：

```php
// 取得某個使用者建立的所有團購
$user = User::find(1);
$groupBuys = $user-&gt;groupBuys;  // Collection of GroupBuy

// 取得某個團購的建立者
$groupBuy = GroupBuy::find(1);
$organizer = $groupBuy-&gt;organizer;  // User instance

// 建立關聯資料
$user-&gt;groupBuys()-&gt;create([
    &apos;title&apos; =&gt; &apos;辦公室下午茶團&apos;,
    &apos;min_participants&apos; =&gt; 5,
    &apos;deadline&apos; =&gt; now()-&gt;addDays(3),
]);
```

&gt; **注意 `$user-&gt;groupBuys` 和 `$user-&gt;groupBuys()` 的差異：** 不帶括號的是「動態屬性」，直接回傳結果（Collection）；帶括號的是「關聯查詢」，回傳 Builder，你可以繼續加條件再 `-&gt;get()`。

### 多對多（belongsToMany）

一個使用者可以參加多個團購，一個團購也有多個參與者——這是多對多關係。需要一張中間表（pivot table）：

```php
// Migration：建立 pivot table
Schema::create(&apos;group_buy_user&apos;, function (Blueprint $table) {
    $table-&gt;id();
    $table-&gt;foreignId(&apos;group_buy_id&apos;)-&gt;constrained()-&gt;cascadeOnDelete();
    $table-&gt;foreignId(&apos;user_id&apos;)-&gt;constrained()-&gt;cascadeOnDelete();
    $table-&gt;integer(&apos;quantity&apos;)-&gt;default(1);  // 跟團數量
    $table-&gt;timestamps();

    $table-&gt;unique([&apos;group_buy_id&apos;, &apos;user_id&apos;]);  // 同一人不能重複加入
});
```

```php
// GroupBuy Model
class GroupBuy extends Model
{
    public function participants(): BelongsToMany
    {
        return $this-&gt;belongsToMany(User::class)
            -&gt;withPivot(&apos;quantity&apos;)   // 載入中間表的額外欄位
            -&gt;withTimestamps();       // 載入中間表的時間戳
    }
}

// User Model
class User extends Authenticatable
{
    public function joinedGroupBuys(): BelongsToMany
    {
        return $this-&gt;belongsToMany(GroupBuy::class)
            -&gt;withPivot(&apos;quantity&apos;)
            -&gt;withTimestamps();
    }
}
```

使用方式：

```php
// 某個團購的所有參與者
$groupBuy = GroupBuy::find(1);
$participants = $groupBuy-&gt;participants;  // Collection of User

// 某個參與者的跟團數量（從 pivot 拿）
foreach ($groupBuy-&gt;participants as $user) {
    echo &quot;{$user-&gt;name} 跟了 {$user-&gt;pivot-&gt;quantity} 份&quot;;
}

// 使用者加入團購
$groupBuy-&gt;participants()-&gt;attach($userId, [&apos;quantity&apos; =&gt; 2]);

// 使用者退出團購
$groupBuy-&gt;participants()-&gt;detach($userId);

// 更新跟團數量
$groupBuy-&gt;participants()-&gt;updateExistingPivot($userId, [&apos;quantity&apos; =&gt; 3]);

// 同步（覆蓋所有關聯）
$groupBuy-&gt;participants()-&gt;sync([
    $userId1 =&gt; [&apos;quantity&apos; =&gt; 1],
    $userId2 =&gt; [&apos;quantity&apos; =&gt; 3],
]);

// 計算參與人數
$count = $groupBuy-&gt;participants()-&gt;count();
```

### 關聯一覽

| 關聯類型   | 方法             | 範例                     |
| ---------- | ---------------- | ------------------------ |
| 一對多     | `hasMany`        | 使用者 → 多個團購        |
| 多對一     | `belongsTo`      | 團購 → 屬於一個使用者    |
| 多對多     | `belongsToMany`  | 使用者 ↔ 團購（參與者）  |
| 一對一     | `hasOne`         | 使用者 → 一個設定檔      |
| 透過中間表 | `hasManyThrough` | 使用者 → 訂單 → 訂單項目 |
| 多型關聯   | `morphMany`      | 圖片 → 可屬於商品或團購  |

一對多和多對多是最常用的，其他的等碰到再學就好。

## Query Builder vs Eloquent：什麼時候用哪個

Eloquent 底層其實是包裝了 Laravel 的 Query Builder。有些情況下直接用 Query Builder 更適合：

```php
// Eloquent——回傳 Model 物件，有 relationship、events、accessors
$products = Product::where(&apos;is_active&apos;, true)-&gt;get();

// Query Builder——回傳 stdClass 物件，輕量、快速
$products = DB::table(&apos;products&apos;)-&gt;where(&apos;is_active&apos;, true)-&gt;get();
```

### 什麼時候用 Query Builder？

| 場景          | 建議          | 原因                                           |
| ------------- | ------------- | ---------------------------------------------- |
| 一般 CRUD     | Eloquent      | 有 Model 的所有功能                            |
| 複雜報表/統計 | Query Builder | 不需要 Model 實例，效能更好                    |
| 批次更新/刪除 | Query Builder | Eloquent 會一筆一筆觸發 Event，慢              |
| JOIN 查詢     | 看情況        | 簡單的用 Eloquent 關聯，複雜的用 Query Builder |

```php
// 報表範例：統計每個團購的參與人數和總金額
$stats = DB::table(&apos;group_buys&apos;)
    -&gt;join(&apos;group_buy_user&apos;, &apos;group_buys.id&apos;, &apos;=&apos;, &apos;group_buy_user.group_buy_id&apos;)
    -&gt;select(
        &apos;group_buys.title&apos;,
        DB::raw(&apos;COUNT(group_buy_user.user_id) as participant_count&apos;),
        DB::raw(&apos;SUM(group_buy_user.quantity) as total_quantity&apos;),
    )
    -&gt;groupBy(&apos;group_buys.id&apos;, &apos;group_buys.title&apos;)
    -&gt;get();
```

&gt; **實務建議：** 90% 的情況用 Eloquent 就好。只有在效能敏感的報表或批次操作時，才需要降到 Query Builder。過早優化是萬惡之源——先讓程式碼清晰，有效能問題再處理。

## Seeder 與 Factory：自動產生測試資料

每次 `migrate:fresh` 都重新手動建資料很煩。Factory + Seeder 讓你一行指令就填滿測試資料。

### Factory：定義假資料長什麼樣

```bash
php artisan make:factory ProductFactory
```

```php
&lt;?php

namespace Database\Factories;

use Illuminate\Database\Eloquent\Factories\Factory;

class ProductFactory extends Factory
{
    public function definition(): array
    {
        $snacks = [&apos;鳳梨酥&apos;, &apos;太陽餅&apos;, &apos;牛軋糖&apos;, &apos;雞排&apos;, &apos;珍珠奶茶&apos;, &apos;芋頭酥&apos;, &apos;蛋黃酥&apos;, &apos;麻糬&apos;, &apos;花生糖&apos;];

        return [
            &apos;name&apos; =&gt; fake()-&gt;randomElement($snacks) . &apos;團購組&apos;,
            &apos;description&apos; =&gt; fake()-&gt;realText(100),
            &apos;price&apos; =&gt; fake()-&gt;numberBetween(5000, 100000),  // $50 ~ $1000
            &apos;is_active&apos; =&gt; fake()-&gt;boolean(80),  // 80% 機率為 true
        ];
    }
}
```

### Seeder：用 Factory 填資料

```bash
php artisan make:seeder ProductSeeder
```

```php
&lt;?php

namespace Database\Seeders;

use App\Models\Product;
use Illuminate\Database\Seeder;

class ProductSeeder extends Seeder
{
    public function run(): void
    {
        Product::factory(30)-&gt;create();  // 建立 30 筆假商品
    }
}
```

在 `DatabaseSeeder` 裡呼叫：

```php
class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        $this-&gt;call([
            ProductSeeder::class,
            GroupBuySeeder::class,
        ]);
    }
}
```

執行：

```bash
php artisan migrate:fresh --seed
```

一行指令：清空資料庫 → 重建所有資料表 → 填入 30 筆假商品。開發效率直接翻倍。

### Factory 進階用法

```php
// 建立特定狀態的資料
Product::factory()
    -&gt;count(10)
    -&gt;create([&apos;is_active&apos; =&gt; false]);  // 10 筆下架商品

// 搭配關聯
User::factory()
    -&gt;has(GroupBuy::factory()-&gt;count(3))  // 每個使用者有 3 個團購
    -&gt;count(5)                             // 建 5 個使用者
    -&gt;create();
```

## 實作：設計揪好買的資料表與 Model

理論到這裡告一段落。讓我們動手為揪好買設計完整的資料模型。

### ER 關係圖

```text
users ─────────&lt; group_buys
  │                  │
  │                  │
  └──────&lt; group_buy_user &gt;──────┘
           (pivot: quantity)
```

- 一個 user 可以建立多個 group_buys（一對多）
- 一個 user 可以參加多個 group_buys（多對多，透過 group_buy_user）
- 一個 group_buy 有多個 participants（多對多）

### Step 1：建立 GroupBuy Migration 與 Model

```bash
php artisan make:model GroupBuy -mfs
```

`database/migrations/xxxx_create_group_buys_table.php`：

```php
public function up(): void
{
    Schema::create(&apos;group_buys&apos;, function (Blueprint $table) {
        $table-&gt;id();
        $table-&gt;foreignId(&apos;user_id&apos;)-&gt;constrained()-&gt;cascadeOnDelete();
        $table-&gt;string(&apos;title&apos;);
        $table-&gt;text(&apos;description&apos;)-&gt;nullable();
        $table-&gt;string(&apos;product_name&apos;);
        $table-&gt;integer(&apos;price_per_unit&apos;);         // 每份單價（分）
        $table-&gt;string(&apos;image&apos;)-&gt;nullable();
        $table-&gt;integer(&apos;min_participants&apos;);         // 最低成團人數
        $table-&gt;integer(&apos;max_participants&apos;)-&gt;nullable(); // 最高人數（null 表示不限）
        $table-&gt;dateTime(&apos;deadline&apos;);               // 截止時間
        $table-&gt;string(&apos;status&apos;)-&gt;default(&apos;open&apos;);  // open / confirmed / cancelled / completed
        $table-&gt;timestamps();
    });
}
```

### Step 2：建立 Pivot Table Migration

```bash
php artisan make:migration create_group_buy_user_table
```

```php
public function up(): void
{
    Schema::create(&apos;group_buy_user&apos;, function (Blueprint $table) {
        $table-&gt;id();
        $table-&gt;foreignId(&apos;group_buy_id&apos;)-&gt;constrained()-&gt;cascadeOnDelete();
        $table-&gt;foreignId(&apos;user_id&apos;)-&gt;constrained()-&gt;cascadeOnDelete();
        $table-&gt;integer(&apos;quantity&apos;)-&gt;default(1);
        $table-&gt;text(&apos;note&apos;)-&gt;nullable();  // 跟團備註（例如：不要辣）
        $table-&gt;timestamps();

        $table-&gt;unique([&apos;group_buy_id&apos;, &apos;user_id&apos;]);
    });
}
```

### Step 3：設定 Model

`app/Models/GroupBuy.php`：

```php
&lt;?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

class GroupBuy extends Model
{
    use HasFactory;

    protected $fillable = [
        &apos;title&apos;,
        &apos;description&apos;,
        &apos;product_name&apos;,
        &apos;price_per_unit&apos;,
        &apos;image&apos;,
        &apos;min_participants&apos;,
        &apos;max_participants&apos;,
        &apos;deadline&apos;,
        &apos;status&apos;,
    ];

    protected function casts(): array
    {
        return [
            &apos;deadline&apos; =&gt; &apos;datetime&apos;,
            &apos;price_per_unit&apos; =&gt; &apos;integer&apos;,
            &apos;min_participants&apos; =&gt; &apos;integer&apos;,
            &apos;max_participants&apos; =&gt; &apos;integer&apos;,
        ];
    }

    // ── 關聯 ──

    public function organizer(): BelongsTo
    {
        return $this-&gt;belongsTo(User::class, &apos;user_id&apos;);
    }

    public function participants(): BelongsToMany
    {
        return $this-&gt;belongsToMany(User::class)
            -&gt;withPivot(&apos;quantity&apos;, &apos;note&apos;)
            -&gt;withTimestamps();
    }

    // ── 查詢 Scope ──

    public function scopeOpen($query)
    {
        return $query-&gt;where(&apos;status&apos;, &apos;open&apos;)
            -&gt;where(&apos;deadline&apos;, &apos;&gt;&apos;, now());
    }

    // ── 計算屬性 ──

    public function isConfirmed(): bool
    {
        return $this-&gt;participants()-&gt;count() &gt;= $this-&gt;min_participants;
    }

    public function totalQuantity(): int
    {
        return $this-&gt;participants()-&gt;sum(&apos;group_buy_user.quantity&apos;);
    }
}
```

在 `app/Models/User.php` 加上關聯：

```php
// 我建立的團購
public function groupBuys(): HasMany
{
    return $this-&gt;hasMany(GroupBuy::class);
}

// 我參加的團購
public function joinedGroupBuys(): BelongsToMany
{
    return $this-&gt;belongsToMany(GroupBuy::class)
        -&gt;withPivot(&apos;quantity&apos;, &apos;note&apos;)
        -&gt;withTimestamps();
}
```

### Step 4：建立 Factory 和 Seeder

`database/factories/GroupBuyFactory.php`：

```php
public function definition(): array
{
    $items = [
        &apos;辦公室下午茶團&apos;, &apos;手工餅乾團&apos;, &apos;產地直送水果箱&apos;,
        &apos;日本零食福袋&apos;, &apos;中秋月餅禮盒&apos;, &apos;過年伴手禮團&apos;,
        &apos;咖啡豆合購&apos;, &apos;手搖飲團購券&apos;, &apos;健身便當週餐&apos;,
    ];

    return [
        &apos;user_id&apos; =&gt; User::factory(),
        &apos;title&apos; =&gt; fake()-&gt;randomElement($items),
        &apos;description&apos; =&gt; fake()-&gt;realText(80),
        &apos;product_name&apos; =&gt; fake()-&gt;randomElement([&apos;鳳梨酥&apos;, &apos;太陽餅&apos;, &apos;手工餅乾&apos;, &apos;精品咖啡豆&apos;]),
        &apos;price_per_unit&apos; =&gt; fake()-&gt;numberBetween(5000, 80000),
        &apos;min_participants&apos; =&gt; fake()-&gt;numberBetween(3, 10),
        &apos;max_participants&apos; =&gt; fake()-&gt;optional(0.5)-&gt;numberBetween(10, 50),
        &apos;deadline&apos; =&gt; fake()-&gt;dateTimeBetween(&apos;now&apos;, &apos;+14 days&apos;),
        &apos;status&apos; =&gt; &apos;open&apos;,
    ];
}
```

### Step 5：跑起來

```bash
php artisan migrate:fresh --seed
php artisan tinker
```

```php
&gt;&gt;&gt; GroupBuy::open()-&gt;count()
&gt;&gt;&gt; GroupBuy::first()-&gt;participants
&gt;&gt;&gt; GroupBuy::first()-&gt;organizer-&gt;name
&gt;&gt;&gt; User::first()-&gt;joinedGroupBuys
```

揪好買的資料骨架完成了。

## 小結：Eloquent 讓你專注在業務邏輯

這一章我們走過了 Eloquent 的完整核心：

- **Migration**——用程式碼管理資料表結構，版本控制不再是問題
- **Model**——每張表一個 PHP 類別，命名慣例自動對應
- **CRUD**——`create()`、`find()`、`where()`、`update()`、`delete()`
- **Mass Assignment**——`$fillable` 白名單防護批次賦值攻擊
- **Relationships**——`hasMany`、`belongsTo`、`belongsToMany`，一行定義關聯
- **Query Builder**——複雜查詢和報表的利器
- **Factory &amp; Seeder**——一行指令產生大量測試資料

最重要的是，我們為揪好買建立了核心資料模型：

- `users`——使用者
- `group_buys`——團購，有開團者（一對多）和參與者（多對多）
- `group_buy_user`——多對多中間表，記錄跟團數量

下一章我們要用 [**Blade + Livewire**](/blog/laravel-guide-blade-livewire-frontend/) 把這些資料變成使用者看得到、摸得到的介面——團購列表頁、即時搜尋、動態更新跟團人數。資料有了，接下來蓋 UI。本章建立的 `group_buys` 與 `group_buy_user` 資料模型，也會在[揪好買核心業務邏輯](/blog/laravel-guide-group-buy-logic-session/)一章直接派上用場。</content:encoded><media:content url="https://bobochen.dev/_astro/cover.CjtqppxE.webp" medium="image"/><category>PHP</category><category>Laravel</category><category>Eloquent</category><category>ORM</category><category>Database</category><category>Migration</category><enclosure url="https://bobochen.dev/_astro/cover.CjtqppxE.webp" length="0" type="image/png"/></item><item><title>Laravel 的魔法與紀律：Request Lifecycle、Service Container 與 Middleware</title><link>https://bobochen.dev/blog/laravel-guide-lifecycle-container-middleware/</link><guid isPermaLink="true">https://bobochen.dev/blog/laravel-guide-lifecycle-container-middleware/</guid><description>深入拆解 Laravel 的三大核心機制：一個 HTTP request 從 public/index.php 進來到回傳 response 的完整 Request Lifecycle、作為框架心臟的 Service Container 依賴注入，以及像洋蔥層層包裹的 Middleware。搞懂這三者，從「會用 Laravel」升級成「理解 Laravel」，debug 速度快三倍。</description><pubDate>Tue, 18 Mar 2025 00:00:00 GMT</pubDate><content:encoded>用 Laravel 寫程式的時候，你有沒有一種「魔法」的感覺？Controller 的參數會自動注入、Middleware 不知道在哪裡就生效了、Facade 明明是靜態呼叫卻能在測試裡被 mock。這些看起來很酷，但如果你不理解背後的機制，遲早會在 debug 的時候撞牆——因為你不知道東西是從哪裡冒出來的。

這一章我們要拆解 Laravel 最核心的三個概念：**Request Lifecycle**（一個 HTTP 請求從進來到回去的完整旅程）、**Service Container**（Laravel 的依賴注入容器，也是整個框架的心臟）、以及 **Middleware**（請求的過濾器與守門員）。這三樣東西搞懂了，你就從「會用 Laravel」升級成「理解 Laravel」，debug 的時候也比較知道該往哪裡找。

我們也會在揪好買專案裡實際動手：寫一個 Request 記錄 Middleware，讓每個進來的請求都留下足跡；註冊第一個 Service Provider，體會 Container 的運作方式。理論搭配實作，理解才會紮實。

## 一個 Request 的旅程：從瀏覽器到 Response

[上一章](/blog/laravel-guide-setup-first-route/)我們看過「六步驟概覽」，這次我們用更精細的視角來追蹤。當使用者在瀏覽器輸入 `http://localhost:8000/` 按下 Enter，以下是完整的旅程：

```
瀏覽器 → Nginx/Apache → public/index.php
  → Bootstrap Application (bootstrap/app.php)
  → Service Providers (register + boot)
  → Global Middleware（依序執行）
  → Route Middleware（針對特定路由）
  → Router（比對 URL → Controller）
  → Controller 處理邏輯
  → Response（反向穿越 Middleware）
  → 瀏覽器收到 HTML
```

### 起點：public/index.php

整個 Laravel 應用只有一個入口——`public/index.php`。不管使用者訪問 `/`、`/products/123` 還是 `/api/orders`，**所有請求都從這裡進來**。這叫做「Front Controller」模式。

```php
// public/index.php（Laravel 11/12/13 實際版本）
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;

define(&apos;LARAVEL_START&apos;, microtime(true));

// 檢查維護模式...
if (file_exists($maintenance = __DIR__.&apos;/../storage/framework/maintenance.php&apos;)) {
    require $maintenance;
}

// 載入 Composer autoloader...
require __DIR__.&apos;/../vendor/autoload.php&apos;;

// 啟動 Laravel 並處理請求...
/** @var Application $app */
$app = require_once __DIR__.&apos;/../bootstrap/app.php&apos;;

$app-&gt;handleRequest(Request::capture());
```

三件事，從上到下：

1. **載入 Composer autoloader**——讓所有 class 可以自動載入
2. **建立 Application 實例**——這就是 Service Container 本人
3. **`handleRequest()` 一行搞定全流程**——內部仍會走 HTTP Kernel（Middleware → 路由 → Controller），產出 Response 並送回瀏覽器，善後任務也在此完成

&gt; **Express 開發者的類比：** `public/index.php` 就像你的 `server.js` 入口。Front Controller 模式等同於 Express 的 `app.use()` 中介軟體管線——所有請求都通過同一條管線。

### bootstrap/app.php：應用程式的組裝說明書

從 Laravel 11 開始，`bootstrap/app.php` 取代了舊版的 Http Kernel 和 Console Kernel，成為應用程式的唯一組裝設定檔：

```php
// bootstrap/app.php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;

return Application::configure(basePath: dirname(__DIR__))
    -&gt;withRouting(
        web: __DIR__.&apos;/../routes/web.php&apos;,
        commands: __DIR__.&apos;/../routes/console.php&apos;,
        health: &apos;/up&apos;,
    )
    -&gt;withMiddleware(function (Middleware $middleware) {
        // 在這裡自訂 Middleware
    })
    -&gt;withExceptions(function (Exceptions $exceptions) {
        // 在這裡自訂例外處理
    })
    -&gt;create();
```

三個 `with` 方法，分別設定路由、Middleware 和例外處理。整個 Laravel 的啟動設定就在這一個檔案裡，乾淨又集中。

## Service Container：Laravel 的心臟

Service Container 是 Laravel 最重要的概念，沒有之一。如果你只能從這章記住一件事，就記住這個。

### 什麼是 Service Container？

用最白話的方式說：Service Container 是一個**物件工廠**。你告訴它「我需要一個 X」，它就幫你把 X 造出來——包括 X 依賴的 Y 和 Z，也一併搞定。

```php
// 你不用這樣寫（手動建立所有依賴）
$logger = new FileLogger(&apos;/var/log/app.log&apos;);
$mailer = new SmtpMailer(&apos;smtp.gmail.com&apos;, 587, $credentials);
$notifier = new OrderNotifier($mailer, $logger);
$controller = new OrderController($notifier);

// Container 幫你搞定一切——你只需要「要」
$controller = app(OrderController::class);
// Container 自動解析所有依賴，遞迴建立
```

### 為什麼需要它？

想像你在蓋一棟房子。沒有 Container 的世界裡，你得自己去買磚、拌水泥、叫水電工、找油漆師傅——每蓋一棟都從頭來。有 Container 就像有個包工頭：你說「我要一棟三房兩廳」，他幫你搞定所有上下游。

實際好處：

1. **解耦合**——你的程式碼不需要知道「怎麼建立」依賴，只需要宣告「我需要什麼」
2. **可測試**——測試時可以替換假的依賴（mock），不用動到真正的資料庫或郵件服務
3. **靈活性**——想換 Logger 實作？改一處設定，全站生效

### 跨語言對照

| 概念          | Laravel                  | Spring (Java)      | NestJS (Node.js)      | Python     |
| ------------- | ------------------------ | ------------------ | --------------------- | ---------- |
| IoC Container | Service Container        | ApplicationContext | Module + providers    | 通常不內建 |
| 註冊服務      | `bind()` / `singleton()` | `@Bean`            | `@Injectable()`       | —          |
| 自動注入      | Type-hint in constructor | `@Autowired`       | Constructor injection | —          |

&gt; **JS/Python 開發者注意：** JavaScript 和 Python 社群通常不使用 IoC Container（因為語言的動態性質讓手動 DI 比較容易）。但在 PHP 和 Java 的世界裡，Container 是框架的基石。如果這個概念對你來說很新，不用擔心——跟著往下看，很快就會上手。

### 基本操作

```php
// 1. 綁定：告訴 Container「當有人要 X 的時候，這樣造」
app()-&gt;bind(PaymentGateway::class, function () {
    return new StripeGateway(config(&apos;services.stripe.secret&apos;));
});

// 2. 解析：「我需要一個 PaymentGateway」
$gateway = app(PaymentGateway::class);
// Container 執行上面的 closure，回傳 StripeGateway 實例

// 3. 單例模式：整個 request 只建立一次
app()-&gt;singleton(ShoppingCart::class, function () {
    return new ShoppingCart();
});
// 不管你 resolve 幾次，拿到的都是同一個實例
```

### 介面綁定：最強大的用法

```php
// 綁定介面到實作
app()-&gt;bind(
    PaymentGatewayInterface::class,
    StripeGateway::class
);

// 之後不管哪裡需要 PaymentGatewayInterface
// Container 都會自動給你 StripeGateway

// 想換成 ECPay？改這一行就好
app()-&gt;bind(
    PaymentGatewayInterface::class,
    EcPayGateway::class
);
```

這就是「面向介面編程」的威力——你的 Controller 不知道也不關心後面是 Stripe 還是 ECPay，它只跟介面說話。

## 依賴注入：不用自己 new 物件

依賴注入（Dependency Injection, DI）是 Service Container 最常被使用的方式。簡單說就是：**不要自己建立依賴，讓框架幫你注入**。

### Constructor Injection

最常見也最推薦的注入方式——在 constructor 裡用 type-hint 宣告你需要什麼：

```php
class OrderController extends Controller
{
    public function __construct(
        private OrderService $orderService,
        private PaymentGatewayInterface $gateway,
    ) {}

    public function store(Request $request)
    {
        $order = $this-&gt;orderService-&gt;create($request-&gt;all());
        $this-&gt;gateway-&gt;charge($order-&gt;total);

        return redirect(&apos;/orders/&apos; . $order-&gt;id);
    }
}
```

你完全沒有寫過 `new OrderService()` 或 `new StripeGateway()`——Container 看到 constructor 的 type-hint，自動幫你建立並注入。

### Method Injection

在 Controller 的方法裡也可以注入：

```php
class ProductController extends Controller
{
    // Request 自動注入、Product 自動透過 Route Model Binding 注入
    public function show(Request $request, Product $product)
    {
        return view(&apos;products.show&apos;, compact(&apos;product&apos;));
    }
}
```

`Request` 是 Laravel 的 HTTP 請求物件，`Product` 是透過 URL 參數自動查到的 Eloquent Model（這叫 Route Model Binding，[第四章](/blog/laravel-guide-eloquent-orm-models/)會詳細介紹）。兩個都是 Container 自動注入的。

### 自動解析的魔法

Container 怎麼知道要給你什麼？它的邏輯很簡單：

1. 看 constructor 的 type-hint
2. 如果是具體類別（如 `OrderService`）→ 直接 `new` 出來（遞迴解析它的依賴）
3. 如果是介面（如 `PaymentGatewayInterface`）→ 查綁定表，找到對應的實作
4. 如果找不到 → 丟出 `BindingResolutionException`

大多數時候你不需要手動 `bind()`。只要你的類別 constructor 裡用的是具體類別，Container 會自動解析，完全不需要設定。只有當你用介面的時候，才需要告訴 Container「這個介面對應哪個實作」。

## Service Provider：應用程式的啟動清單

Service Provider 是你告訴 Container「要綁定哪些東西」的地方。每個 Laravel 應用在啟動時都會跑過一系列的 Service Provider，把需要的服務註冊到 Container 裡。

### 結構

```php
&lt;?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    /**
     * 註冊階段：綁定服務到 Container
     * 這裡只做 bind/singleton，不要存取其他服務
     */
    public function register(): void
    {
        $this-&gt;app-&gt;bind(
            PaymentGatewayInterface::class,
            StripeGateway::class
        );
    }

    /**
     * 啟動階段：所有 Provider 都 register 完了，可以安全地使用任何服務
     * 適合放 Event Listener、Route Model Binding、View Composer 等
     */
    public function boot(): void
    {
        // 例如：全站共用的 View 變數
        view()-&gt;share(&apos;appName&apos;, config(&apos;app.name&apos;));
    }
}
```

### register() vs boot()

這兩個方法的執行順序很重要：

```
Application 啟動
  ↓
所有 Provider 的 register() 依序執行
  ↓ （此時所有服務都已註冊到 Container）
所有 Provider 的 boot() 依序執行
  ↓
Application 準備好接收 Request
```

| 方法         | 用途     | 能做什麼                | 不該做什麼                     |
| ------------ | -------- | ----------------------- | ------------------------------ |
| `register()` | 註冊綁定 | `bind()`、`singleton()` | 使用其他服務（可能還沒被註冊） |
| `boot()`     | 啟動設定 | 用任何已註冊的服務      | 不該再做綁定（太晚了）         |

&gt; **類比：** 想像一場派對。`register()` 是「大家把食物帶來放桌上」，`boot()` 是「所有食物都到了，開始擺盤和布置」。你不能在擺盤的時候才發現主菜還沒到。

### 建立自訂 Provider

```bash
php artisan make:provider PaymentServiceProvider
```

```php
&lt;?php

namespace App\Providers;

use App\Services\Payment\EcPayGateway;
use App\Services\Payment\PaymentGatewayInterface;
use App\Services\Payment\StripeGateway;
use Illuminate\Support\ServiceProvider;

class PaymentServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $gateway = match (config(&apos;payment.default&apos;)) {
            &apos;ecpay&apos; =&gt; EcPayGateway::class,
            default =&gt; StripeGateway::class,
        };

        $this-&gt;app-&gt;singleton(PaymentGatewayInterface::class, $gateway);
    }
}
```

用 `make:provider` 建立時，Laravel 11 起會自動把新 Provider 寫入 `bootstrap/providers.php`，你不需要手動編輯，只要打開確認它已經在陣列裡即可：

```php
return [
    App\Providers\AppServiceProvider::class,
    App\Providers\PaymentServiceProvider::class, // make:provider 已自動加入
];
```

&gt; **注意：** 只有當你手動建立 Provider 檔案（沒用 `make:provider`）時，才需要自己把它加進這個陣列。用指令產生卻又手動再加一次，會造成重複註冊。

## Middleware：Request 的過濾器

Middleware 是一個「洋蔥」——每一層 Middleware 包裹著下一層，Request 從外層進去、穿過 Controller、Response 再從內層出來。

```
Request → [Middleware A → [Middleware B → [Controller] → Middleware B] → Middleware A] → Response
```

### Middleware 做什麼？

常見用途：

- **認證檢查**——沒登入？打回登入頁
- **CSRF 防護**——POST 請求必須帶 token
- **Rate Limiting**——限制 API 呼叫頻率
- **CORS 處理**——設定跨域 headers
- **日誌記錄**——記錄每個 request 的資訊

### 建立自訂 Middleware

```bash
php artisan make:middleware LogRequest
```

```php
&lt;?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;

class LogRequest
{
    public function handle(Request $request, Closure $next): Response
    {
        // ① 在 Request 進入 Controller 之前做事
        $start = microtime(true);

        // ② 把 Request 傳給下一層（最終到 Controller）
        $response = $next($request);

        // ③ Response 出來之後做事
        $duration = round((microtime(true) - $start) * 1000, 2);

        Log::info(&apos;Request processed&apos;, [
            &apos;method&apos; =&gt; $request-&gt;method(),
            &apos;url&apos; =&gt; $request-&gt;fullUrl(),
            &apos;status&apos; =&gt; $response-&gt;getStatusCode(),
            &apos;duration_ms&apos; =&gt; $duration,
            &apos;ip&apos; =&gt; $request-&gt;ip(),
        ]);

        return $response;
    }
}
```

Middleware 的結構永遠是這樣：

1. `$next($request)` **之前**——處理 Request（前置作業）
2. `$next($request)`——把 Request 傳遞下去
3. `$next($request)` **之後**——處理 Response（後置作業）

&gt; **Express 開發者對照：** 這就是 Express 的 `(req, res, next) =&gt; { ... next(); ... }`。概念完全一樣，只是 Laravel 把 `$next($request)` 的回傳值當作 Response。

### 註冊 Middleware

在 `bootstrap/app.php` 裡設定：

```php
-&gt;withMiddleware(function (Middleware $middleware) {
    // 全域 Middleware——每個 Request 都會經過
    $middleware-&gt;append(LogRequest::class);

    // 路由群組 Middleware
    $middleware-&gt;appendToGroup(&apos;api&apos;, [
        // API 專用 Middleware
    ]);

    // 路由別名——在個別路由上使用
    $middleware-&gt;alias([
        &apos;admin&apos; =&gt; EnsureUserIsAdmin::class,
    ]);
})
```

### 在路由上使用 Middleware

```php
// 用在單一路由
Route::get(&apos;/admin/dashboard&apos;, [AdminController::class, &apos;dashboard&apos;])
    -&gt;middleware(&apos;admin&apos;);

// 用在路由群組
Route::middleware([&apos;auth&apos;, &apos;admin&apos;])-&gt;group(function () {
    Route::get(&apos;/admin/dashboard&apos;, [AdminController::class, &apos;dashboard&apos;]);
    Route::get(&apos;/admin/users&apos;, [AdminController::class, &apos;users&apos;]);
});
```

### Laravel 內建的重要 Middleware

| Middleware         | 用途             | 對應的路由群組 |
| ------------------ | ---------------- | -------------- |
| `EncryptCookies`   | 加密 Cookie      | web            |
| `VerifyCsrfToken`  | 驗證 CSRF Token  | web            |
| `StartSession`     | 啟動 Session     | web            |
| `ThrottleRequests` | API 頻率限制     | api            |
| `auth`             | 驗證使用者已登入 | 自訂使用       |
| `guest`            | 驗證使用者未登入 | 自訂使用       |

`web` 群組預設套用在 `routes/web.php` 的所有路由上，你不用手動加——這也是「魔法」之一，但現在你知道它是怎麼運作的了。

## Facade vs 依賴注入：該用哪個？

Laravel 的 Facade 讓你可以用靜態語法呼叫服務：

```php
// Facade 寫法
use Illuminate\Support\Facades\Cache;

$value = Cache::get(&apos;key&apos;);

// 依賴注入寫法
use Illuminate\Contracts\Cache\Repository;

class MyService
{
    public function __construct(
        private Repository $cache,
    ) {}

    public function getValue()
    {
        return $this-&gt;cache-&gt;get(&apos;key&apos;);
    }
}
```

兩種寫法在底層做的事情**完全一樣**——Facade 只是一個語法糖，它在背後去 Container 拿服務。

### 什麼時候用哪個？

| 場景           | 建議                         | 原因                             |
| -------------- | ---------------------------- | -------------------------------- |
| Controller     | 依賴注入                     | 明確宣告依賴，好測試             |
| Service 類別   | 依賴注入                     | 同上                             |
| Blade 模板     | Facade 或 Helper             | `@auth` 就是 Facade 的語法糖     |
| 設定檔         | Helper (`config()`, `env()`) | 沒有 class，無法注入             |
| 快速 prototype | Facade                       | 先求有，重構時再換               |
| 測試           | 都行                         | Facade 有 `::fake()`，DI 有 mock |

### 實務上的建議

如果你剛從 JS/Python 轉過來，**先用 Facade 沒問題**。它的學習曲線低，而且 Laravel 的 Facade 設計得很好——不會造成全域狀態的問題（每個 request 結束後都會清除）。

等你對框架更熟了，自然會開始在 Service 層用依賴注入。不用一開始就追求「完美的架構」，那只會讓你寫不出東西。

```php
// 這樣寫完全沒問題，很多 Laravel 專案都這樣
class OrderController extends Controller
{
    public function index()
    {
        $orders = Order::where(&apos;user_id&apos;, Auth::id())
            -&gt;latest()
            -&gt;paginate(20);

        return view(&apos;orders.index&apos;, compact(&apos;orders&apos;));
    }
}

// 想要更 testable？重構成 DI 也不難
class OrderController extends Controller
{
    public function __construct(
        private OrderService $orders,
    ) {}

    public function index(Request $request)
    {
        $orders = $this-&gt;orders-&gt;listForUser($request-&gt;user());

        return view(&apos;orders.index&apos;, compact(&apos;orders&apos;));
    }
}
```

## 實作：為揪好買加入 Request 記錄 Middleware

理論講完了，讓我們在揪好買專案裡動手做。

### Step 1：建立 Middleware

```bash
php artisan make:middleware LogRequest
```

編輯 `app/Http/Middleware/LogRequest.php`：

```php
&lt;?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;

class LogRequest
{
    public function handle(Request $request, Closure $next): Response
    {
        $start = microtime(true);

        $response = $next($request);

        $duration = round((microtime(true) - $start) * 1000, 2);

        Log::channel(&apos;single&apos;)-&gt;info(&apos;HTTP Request&apos;, [
            &apos;method&apos; =&gt; $request-&gt;method(),
            &apos;path&apos; =&gt; $request-&gt;path(),
            &apos;status&apos; =&gt; $response-&gt;getStatusCode(),
            &apos;duration_ms&apos; =&gt; $duration,
            &apos;ip&apos; =&gt; $request-&gt;ip(),
            &apos;user_agent&apos; =&gt; $request-&gt;userAgent(),
        ]);

        return $response;
    }
}
```

### Step 2：全域註冊

編輯 `bootstrap/app.php`：

```php
use App\Http\Middleware\LogRequest;

return Application::configure(basePath: dirname(__DIR__))
    -&gt;withRouting(
        web: __DIR__.&apos;/../routes/web.php&apos;,
        commands: __DIR__.&apos;/../routes/console.php&apos;,
        health: &apos;/up&apos;,
    )
    -&gt;withMiddleware(function (Middleware $middleware) {
        $middleware-&gt;append(LogRequest::class);
    })
    -&gt;withExceptions(function (Exceptions $exceptions) {
        //
    })
    -&gt;create();
```

### Step 3：測試

```bash
php artisan serve
# 在另一個終端視窗
curl http://localhost:8000/
```

查看 `storage/logs/laravel.log`：

```
[2026-06-15 10:30:00] local.INFO: HTTP Request {
    &quot;method&quot;: &quot;GET&quot;,
    &quot;path&quot;: &quot;/&quot;,
    &quot;status&quot;: 200,
    &quot;duration_ms&quot;: 42.35,
    &quot;ip&quot;: &quot;127.0.0.1&quot;,
    &quot;user_agent&quot;: &quot;curl/8.20.0&quot;
}
```

每一個進來的 request 都被記錄了。這個 Middleware 在開發階段幫你觀察請求流量，到了 production 也能用來做效能監控。

### Step 4：加入一個簡單的 Service Provider（bonus）

讓我們建立一個 Service Provider，在每個頁面的 View 裡共享揪好買的設定：

```bash
php artisan make:provider JiuHaoMaiServiceProvider
```

```php
&lt;?php

namespace App\Providers;

use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;

class JiuHaoMaiServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // 註冊應用程式設定
        $this-&gt;app-&gt;singleton(&apos;jiuhaomai.config&apos;, function () {
            return [
                &apos;name&apos; =&gt; &apos;揪好買 JiuHaoMai&apos;,
                &apos;slogan&apos; =&gt; &apos;找好物、揪好友、一起買更划算&apos;,
                &apos;version&apos; =&gt; &apos;0.1.0&apos;,
                &apos;min_participants&apos; =&gt; 3,
            ];
        });
    }

    public function boot(): void
    {
        // 讓所有 View 都能用 $jhm 變數
        View::share(&apos;jhm&apos;, app(&apos;jiuhaomai.config&apos;));
    }
}
```

由於是用 `make:provider` 產生的，Laravel 已自動把它寫進 `bootstrap/providers.php`，打開確認一下即可（不需要再手動加）：

```php
return [
    App\Providers\AppServiceProvider::class,
    App\Providers\JiuHaoMaiServiceProvider::class, // make:provider 已自動加入
];
```

現在你可以在任何 Blade 模板裡直接用 `$jhm`：

```html
&lt;footer&gt;
  &lt;p&gt;{{ $jhm[&apos;name&apos;] }} v{{ $jhm[&apos;version&apos;] }} — {{ $jhm[&apos;slogan&apos;] }}&lt;/p&gt;
&lt;/footer&gt;
```

這就是 Service Provider 的威力：**一處設定，全站生效**。

## 小結：理解魔法，掌握紀律

這一章的資訊量很大，讓我們整理一下核心概念：

**Request Lifecycle：**

- 所有請求從 `public/index.php` 進入
- `bootstrap/app.php` 設定路由、Middleware、例外處理
- Request 穿過 Middleware 洋蔥 → Router → Controller → Response 反向穿回

**Service Container：**

- 一個智慧物件工廠，自動解析依賴
- `bind()` 註冊一對一對應，`singleton()` 保證只建立一次
- 介面綁定讓你能輕鬆切換實作

**依賴注入：**

- 在 constructor 或方法裡 type-hint，Container 自動注入
- 具體類別不用設定，介面需要 `bind()` 綁定

**Service Provider：**

- `register()` 註冊綁定，`boot()` 設定啟動邏輯
- 所有 `register()` 跑完才開始跑 `boot()`

**Middleware：**

- Request 的前置/後置過濾器
- `$next($request)` 前後分別處理 request 和 response
- 在 `bootstrap/app.php` 設定全域、群組或路由別名

**Facade vs DI：**

- 兩種方式底層一樣，都從 Container 取服務
- 新手先用 Facade 沒問題，熟了再用 DI 提升可測試性

下一章我們進入 Laravel 最迷人的部分——[**Eloquent ORM**](/blog/laravel-guide-eloquent-orm-models/)。你會學到如何用優雅的 PHP 程式碼操作資料庫，完全不用寫 SQL。我們也會開始設計揪好買的資料表：使用者、團購、商品、參團紀錄。</content:encoded><media:content url="https://bobochen.dev/_astro/cover.CZYdicmj.webp" medium="image"/><category>PHP</category><category>Laravel</category><category>Service Container</category><category>Middleware</category><category>Dependency Injection</category><enclosure url="https://bobochen.dev/_astro/cover.CZYdicmj.webp" length="0" type="image/png"/></item><item><title>Laravel 12 起手式：從 Composer 到第一個 Route 的十分鐘</title><link>https://bobochen.dev/blog/laravel-guide-setup-first-route/</link><guid isPermaLink="true">https://bobochen.dev/blog/laravel-guide-setup-first-route/</guid><description>從零安裝 Laravel 12——用 composer create-project 或 laravel new 一行建立專案，搞懂目錄結構、Artisan CLI、Route 路由與 Blade 模板，再用 .env 管理環境變數，十分鐘做出第一個首頁。</description><pubDate>Tue, 11 Mar 2025 00:00:00 GMT</pubDate><content:encoded>Laravel 是 PHP 生態系中最受歡迎的框架，沒有之一。它的設計哲學很簡單：讓開發者把時間花在業務邏輯上，而不是重複造輪子。從路由、資料庫、認證、佇列到排程任務，Laravel 全部幫你準備好了——而且 API 設計得優雅到你會覺得寫程式是一種享受。

但在享受之前，你得先讓它跑起來。好消息是，Laravel 的安裝流程已經簡化到一行指令就搞定。

&gt; **版本說明（2026 年）：** Laravel 已於 2026-03-17 推出 **Laravel 13**（最新版，最低需求提升至 PHP 8.3+，主打 AI-native 工作流、JSON:API resources、向量／語意搜尋等）。本章內容以 **Laravel 12** 為基準撰寫，整本書的範例與目錄結構導覽都以 12 為準；如果你用 `composer create-project` 或 `laravel new` 預設安裝，現在很可能裝到的是 Laravel 13。兩版的起手式（安裝、Route、Blade、`.env`）幾乎一致，本章內容仍然適用；差別主要在 Laravel 13 要求 PHP 8.3+，且部分模型／控制器等可改用新的 PHP attribute 寫法。想完全照本章操作，可在 `composer create-project` 後面指定版本：`composer create-project laravel/laravel:&quot;^12.0&quot; jiu-hao-mai`。

這一章我們要做的事情很明確：裝好 Laravel、搞懂目錄結構、寫出第一個 Route 和 View。十分鐘之後，你的瀏覽器上會出現「揪好買」的首頁——這是我們整本書會一起打造的台灣團購平台專案。

不用擔心目錄裡那一堆資料夾看起來很嚇人。每個資料夾都有它明確的職責，我會一個一個帶你認識。學框架最怕的就是「知其然不知其所以然」，所以我們不只要讓它跑起來，還要搞清楚每一步發生了什麼事。

## 安裝 Laravel 12：一行指令搞定

確認你已經裝好 PHP 8.2+ 和 Composer（[上一章 PHP 快速入門](/blog/laravel-guide-php-for-modern-developers) 有教），然後打開終端機：

```bash
composer create-project laravel/laravel jiu-hao-mai
cd jiu-hao-mai
php artisan serve
```

三行指令，打開瀏覽器訪問 `http://localhost:8000`，你就會看到 Laravel 的預設歡迎頁面。

&gt; **專案命名：** 我們把專案取名叫 `jiu-hao-mai`（揪好買的拼音），這是整本書會持續開發的團購平台。

### 另一種安裝方式：Laravel Installer

Laravel 也提供了官方安裝器，它可以在建立專案時互動式地選擇前端堆疊和 Starter Kit：

```bash
# 先安裝 Laravel Installer（只需要做一次）
composer global require laravel/installer

# 用 installer 建立專案
laravel new jiu-hao-mai
```

Installer 會問你幾個問題：要不要 Starter Kit？前端用 React、Vue、Livewire 還是 Svelte？（Laravel 12 用全新的 Starter Kit 取代了舊版的 Breeze 和 Jetstream。）我們這本書在第六章才會加入[認證系統](/blog/laravel-guide-auth-breeze-authorization)，所以現在先選 **No starter kit**，保持乾淨。

### 兩種方式的差異

|                      | `composer create-project` | `laravel new`                 |
| -------------------- | ------------------------- | ----------------------------- |
| 需要先安裝 installer | 不用                      | 要                            |
| 互動式選擇           | 沒有                      | 有（Starter Kit、測試框架等） |
| 適合場景             | 快速建立純淨專案          | 需要一步到位設定完整堆疊      |
| CI/CD 環境           | 比較適合                  | 較不適合（互動式）            |

兩種方式建出來的專案結構完全一樣，選你喜歡的就好。

### 預設資料庫：SQLite

從 Laravel 11 開始，新建專案預設使用 **SQLite** 作為資料庫——不需要安裝 MySQL，開箱即用。專案根目錄會有一個 `database/database.sqlite` 檔案，零設定就能開始開發。等到要部署正式環境時，再換成 MySQL 或 PostgreSQL 也很容易（改個 `.env` 設定就好）。

## 目錄結構導覽：每個資料夾在幹嘛

`cd jiu-hao-mai` 之後，你會看到這樣的目錄結構：

```text
jiu-hao-mai/
├── app/                 # 🧠 你的應用程式核心
│   ├── Http/
│   │   └── Controllers/ # Controller（處理 HTTP 請求的邏輯）
│   ├── Models/          # Eloquent Model（資料庫對應的 PHP 類別）
│   └── Providers/       # Service Provider（應用程式啟動設定）
├── bootstrap/           # 框架啟動程式（通常不需要動）
│   └── app.php          # 應用程式設定與 Middleware 註冊
├── config/              # 設定檔（database, mail, cache 等）
├── database/
│   ├── factories/       # Model Factory（測試資料產生器）
│   ├── migrations/      # Migration（資料表版本控制）
│   ├── seeders/         # Seeder（填充測試資料）
│   └── database.sqlite  # 預設的 SQLite 資料庫
├── public/              # 唯一對外公開的目錄（index.php 在這）
├── resources/
│   ├── css/             # CSS 原始檔
│   ├── js/              # JavaScript 原始檔
│   └── views/           # Blade 模板（HTML）
├── routes/
│   ├── console.php      # Artisan 自訂指令與排程
│   └── web.php          # 🌐 網頁路由（最常編輯的檔案之一）
│   # 注意：api.php 預設不存在，需要時執行 php artisan install:api
├── storage/             # 日誌、快取、上傳檔案
├── tests/               # 測試程式碼
├── .env                 # 環境變數（不進版控）
├── .env.example         # 環境變數範本（進版控）
├── artisan              # Artisan CLI 入口
├── composer.json        # PHP 相依套件
└── package.json         # 前端相依套件
```

&gt; **Laravel 11+ 的精簡骨架：** 如果你看過舊版 Laravel 的教學，可能會好奇 `app/Http/Kernel.php` 和 `app/Console/Kernel.php` 去哪了？從 Laravel 11 開始，框架採用了精簡的應用程式骨架——Kernel 的設定被移到 `bootstrap/app.php`，Middleware 的註冊也在那裡。少了很多「看了不知道要幹嘛」的檔案，新手友善度大幅提升。

### 你最常碰的五個位置

| 位置                    | 用途               | 頻率         |
| ----------------------- | ------------------ | ------------ |
| `routes/web.php`        | 定義 URL 路由      | 每天         |
| `app/Http/Controllers/` | 處理請求邏輯       | 每天         |
| `app/Models/`           | 資料庫 Model       | 經常         |
| `resources/views/`      | Blade 模板（HTML） | 經常         |
| `database/migrations/`  | 資料表結構變更     | 每次改資料庫 |

其他目錄在你需要的時候自然會碰到，現在不用記。

### 與其他框架的對照

如果你從其他框架轉過來，這張表幫你快速對應：

| 概念       | Laravel                    | Express (Node.js)       | Django (Python) | Rails (Ruby)       |
| ---------- | -------------------------- | ----------------------- | --------------- | ------------------ |
| 路由       | `routes/web.php`           | `app.js` / router files | `urls.py`       | `config/routes.rb` |
| 控制器     | `app/Http/Controllers/`    | route handlers          | `views.py`      | `app/controllers/` |
| 模板       | `resources/views/` (Blade) | `views/` (EJS/Pug)      | `templates/`    | `app/views/` (ERB) |
| ORM Model  | `app/Models/` (Eloquent)   | (Prisma/Sequelize)      | `models.py`     | `app/models/`      |
| 資料庫遷移 | `database/migrations/`     | (Prisma/Knex)           | `migrations/`   | `db/migrate/`      |
| 設定       | `config/` + `.env`         | `.env`                  | `settings.py`   | `config/`          |

## php artisan：你的 Laravel 瑞士刀

`artisan` 是 Laravel 的命令列工具，它能幫你做幾乎所有事情——從產生程式碼到管理資料庫、從清快取到啟動開發伺服器。

```bash
# 查看所有可用指令
php artisan list

# 啟動開發伺服器
php artisan serve

# 產生 Controller
php artisan make:controller ProductController

# 產生 Model（順便建 migration 和 factory）
php artisan make:model Product -mf

# 執行資料庫 migration
php artisan migrate

# 列出所有已註冊的路由
php artisan route:list

# 清除各種快取
php artisan cache:clear
php artisan config:clear
php artisan route:clear
php artisan view:clear

# 進入互動式 PHP Shell（像 Node.js 的 REPL 或 Python 的 interactive shell）
php artisan tinker
```

### Tinker：你的即時測試場

`tinker` 是 Laravel 內建的互動式 REPL，讓你可以直接操作 Model、測試邏輯、查詢資料庫——不用啟動瀏覽器：

```bash
php artisan tinker

&gt;&gt;&gt; $user = new App\Models\User;
=&gt; App\Models\User {#1234}

&gt;&gt;&gt; config(&apos;app.name&apos;)
=&gt; &quot;Laravel&quot;

&gt;&gt;&gt; now()-&gt;format(&apos;Y-m-d&apos;)
=&gt; &quot;2026-06-08&quot;

&gt;&gt;&gt; exit
```

&gt; **JS 開發者的類比：** `tinker` 就像 Node.js 的 `node` interactive shell，但它自動載入了整個 Laravel 應用程式。Python 開發者可以想像成 `python manage.py shell`。

### make 指令：程式碼產生器

`make` 系列指令是你最常用的 artisan 指令。它們按照 Laravel 的慣例幫你產生檔案，省掉手動建立和複製貼上：

```bash
php artisan make:controller    # Controller
php artisan make:model         # Eloquent Model
php artisan make:migration     # 資料庫遷移
php artisan make:middleware     # Middleware
php artisan make:request        # Form Request（表單驗證）
php artisan make:policy         # 授權 Policy
php artisan make:command        # 自訂 Artisan 指令
php artisan make:event          # Event
php artisan make:listener       # Event Listener
php artisan make:job            # Queue Job
php artisan make:mail           # Mail
php artisan make:notification   # Notification
php artisan make:test           # 測試
```

不用全背，需要的時候 `php artisan list make` 查就好。

## Route 基礎：URL 對應到程式碼

打開 `routes/web.php`，你會看到預設內容：

```php
&lt;?php

use Illuminate\Support\Facades\Route;

Route::get(&apos;/&apos;, function () {
    return view(&apos;welcome&apos;);
});
```

一行就把根路徑 `/` 對應到 `welcome` 這個 Blade view。這就是 Laravel 路由的核心概念：**定義 URL → 指定處理邏輯 → 回傳 Response**。

### 基本路由寫法

```php
// GET 請求
Route::get(&apos;/about&apos;, function () {
    return view(&apos;about&apos;);
});

// POST 請求
Route::post(&apos;/contact&apos;, function () {
    // 處理表單提交
    return redirect(&apos;/thank-you&apos;);
});

// 帶參數的路由
Route::get(&apos;/products/{id}&apos;, function (string $id) {
    return &quot;商品編號：{$id}&quot;;
});

// 可選參數
Route::get(&apos;/products/{category?}&apos;, function (?string $category = null) {
    return $category ? &quot;分類：{$category}&quot; : &apos;所有商品&apos;;
});
```

### 路由搭配 Controller

在真實專案中，我們不會把邏輯寫在路由檔裡（那會變成一坨義大利麵）。取而代之，我們用 Controller 來處理：

```bash
php artisan make:controller PageController
```

這會在 `app/Http/Controllers/` 產生一個檔案：

```php
&lt;?php

namespace App\Http\Controllers;

class PageController extends Controller
{
    public function home()
    {
        return view(&apos;home&apos;);
    }

    public function about()
    {
        return view(&apos;about&apos;);
    }
}
```

然後在路由裡指向 Controller：

```php
use App\Http\Controllers\PageController;

Route::get(&apos;/&apos;, [PageController::class, &apos;home&apos;]);
Route::get(&apos;/about&apos;, [PageController::class, &apos;about&apos;]);
```

&gt; **Express 開發者注意：** Laravel 的路由語法是 `Route::get(&apos;/path&apos;, [Controller::class, &apos;method&apos;])`。第二個參數不是 callback，而是一個陣列 `[類別, 方法名]`。這跟 Express 的 `router.get(&apos;/path&apos;, controller.method)` 概念一樣，語法不同。

### 查看所有路由

```bash
php artisan route:list
```

```text
GET|HEAD  / ...................................................... PageController@home
GET|HEAD  /about ................................................. PageController@about
```

這個指令會列出所有已註冊的路由、HTTP 方法和對應的 Controller，debug 時超級好用。

## Blade 初體驗：你的第一個 View

Blade 是 Laravel 的模板引擎。如果你用過 EJS（Express）、Jinja2（Python）或 ERB（Rails），概念完全一樣——在 HTML 裡嵌入動態資料。

Blade 檔案放在 `resources/views/`，副檔名是 `.blade.php`。

### 基本語法

```html
&lt;!-- resources/views/home.blade.php --&gt;
&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;zh-TW&quot;&gt;
  &lt;head&gt;
    &lt;meta charset=&quot;UTF-8&quot; /&gt;
    &lt;title&gt;{{ $title }}&lt;/title&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;h1&gt;{{ $title }}&lt;/h1&gt;
    &lt;p&gt;{{ $description }}&lt;/p&gt;

    {{-- 這是 Blade 註解，不會輸出到 HTML --}}
    @if($products-&gt;count() &gt; 0)
    &lt;ul&gt;
      @foreach($products as $product)
      &lt;li&gt;{{ $product-&gt;name }} - ${{ $product-&gt;price }}&lt;/li&gt;
      @endforeach
    &lt;/ul&gt;
    @else
    &lt;p&gt;目前沒有商品&lt;/p&gt;
    @endif
  &lt;/body&gt;
&lt;/html&gt;
```

### Blade 語法速查

| 語法                     | 用途                    | 類比                      |
| ------------------------ | ----------------------- | ------------------------- |
| `{{ $var }}`             | 輸出並自動 HTML 跳脫    | EJS 的 `&lt;%= var %&gt;`       |
| `{!! $html !!}`          | 輸出原始 HTML（不跳脫） | EJS 的 `&lt;%- html %&gt;`      |
| `@if / @else / @endif`   | 條件判斷                | Jinja2 的 `{% if %}`      |
| `@foreach / @endforeach` | 迴圈                    | Jinja2 的 `{% for %}`     |
| `@extends(&apos;layout&apos;)`     | 繼承版面                | Jinja2 的 `{% extends %}` |
| `@section / @yield`      | 定義/填充區塊           | Jinja2 的 `{% block %}`   |
| `{{-- 註解 --}}`         | Blade 註解              | `&lt;!-- --&gt;` 但不輸出       |

### 從 Controller 傳資料給 View

```php
// 在 Controller 裡
public function home()
{
    return view(&apos;home&apos;, [
        &apos;title&apos; =&gt; &apos;揪好買 JiuHaoMai&apos;,
        &apos;description&apos; =&gt; &apos;台灣最有溫度的團購平台&apos;,
    ]);
}
```

`view()` 的第二個參數是一個陣列，key 會變成 View 裡的變數名。`&apos;title&apos; =&gt; &apos;揪好買&apos;` 在 Blade 裡就是 `$title`。

也可以用 `compact()` 語法糖：

```php
public function home()
{
    $title = &apos;揪好買 JiuHaoMai&apos;;
    $description = &apos;台灣最有溫度的團購平台&apos;;

    return view(&apos;home&apos;, compact(&apos;title&apos;, &apos;description&apos;));
}
```

### Layout 機制：避免重複的 HTML

每個頁面都寫一遍 `&lt;html&gt;&lt;head&gt;&lt;body&gt;` 很蠢。Blade 的 Layout 機制幫你解決這個問題：

```html
&lt;!-- resources/views/layouts/app.blade.php --&gt;
&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;zh-TW&quot;&gt;
  &lt;head&gt;
    &lt;meta charset=&quot;UTF-8&quot; /&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot; /&gt;
    &lt;title&gt;@yield(&apos;title&apos;, &apos;揪好買&apos;) | 揪好買 JiuHaoMai&lt;/title&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;nav&gt;
      &lt;a href=&quot;/&quot;&gt;首頁&lt;/a&gt;
      &lt;a href=&quot;/about&quot;&gt;關於我們&lt;/a&gt;
    &lt;/nav&gt;

    &lt;main&gt;@yield(&apos;content&apos;)&lt;/main&gt;

    &lt;footer&gt;
      &lt;p&gt;&amp;copy; 2026 揪好買 JiuHaoMai&lt;/p&gt;
    &lt;/footer&gt;
  &lt;/body&gt;
&lt;/html&gt;
```

```blade
&lt;!-- resources/views/home.blade.php --&gt;
@extends(&apos;layouts.app&apos;)
@section(&apos;title&apos;, &apos;首頁&apos;)
@section(&apos;content&apos;)
&lt;h1&gt;歡迎來到揪好買&lt;/h1&gt;
&lt;p&gt;台灣最有溫度的團購平台&lt;/p&gt;
@endsection
```

`@extends` 指定要繼承哪個 layout，`@section` 填入 layout 裡 `@yield` 留下的空位。概念跟 Django 的 template inheritance 一模一樣。

## .env 設定：環境變數管理

專案根目錄的 `.env` 檔案是 Laravel 的環境設定中心。所有敏感資訊（資料庫密碼、API Key、第三方服務密鑰）都放在這裡，而不是寫死在程式碼中。

```bash
# .env（節錄重要設定）
APP_NAME=&quot;揪好買&quot;
APP_ENV=local
APP_KEY=base64:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
APP_DEBUG=true
APP_URL=http://localhost:8000

DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=jiu_hao_mai
# DB_USERNAME=root
# DB_PASSWORD=

MAIL_MAILER=log
```

### 在程式碼中讀取環境變數

```php
// 方法一：env() 函式（只在 config 檔中使用）
// config/app.php
&apos;name&apos; =&gt; env(&apos;APP_NAME&apos;, &apos;揪好買&apos;),

// 方法二：config() 函式（在應用程式中使用）
$appName = config(&apos;app.name&apos;); // &apos;揪好買&apos;
```

&gt; **重要規則：** 永遠不要在 Controller 或 Model 裡直接呼叫 `env()`。原因是 Laravel 在 production 會快取 config，`env()` 會回傳 `null`。正確做法是在 `config/*.php` 裡用 `env()` 讀取，然後在程式碼裡用 `config()` 存取。這個坑我保證你遲早會踩到，所以現在就記住。

### .env vs .env.example

| 檔案           | 進版控  | 用途                       |
| -------------- | ------- | -------------------------- |
| `.env`         | ❌ 不進 | 真正的環境變數（含密碼）   |
| `.env.example` | ✅ 進   | 環境變數範本（不含真實值） |

團隊開發時，新成員 clone 專案後會複製 `.env.example` 成 `.env`，然後填入自己的設定。

```bash
cp .env.example .env
php artisan key:generate  # 產生 APP_KEY
```

### 與其他框架的比較

| 框架    | 環境變數                         | 設定系統          |
| ------- | -------------------------------- | ----------------- |
| Laravel | `.env` + `config/*.php`          | `config(&apos;key&apos;)`   |
| Express | `.env` + `dotenv` 套件           | `process.env.KEY` |
| Django  | `settings.py` + `django-environ` | `settings.KEY`    |

Laravel 的 `.env` + `config` 兩層架構看起來多一步，但好處是 config 可以快取（`php artisan config:cache`），production 下效能更好。

## 揪好買專案啟動：建立首頁

理論講完了，讓我們動手把「揪好買」的首頁做出來。

### Step 1：建立 Controller

```bash
php artisan make:controller PageController
```

編輯 `app/Http/Controllers/PageController.php`：

```php
&lt;?php

namespace App\Http\Controllers;

class PageController extends Controller
{
    public function home()
    {
        return view(&apos;home&apos;, [
            &apos;title&apos; =&gt; &apos;揪好買 JiuHaoMai&apos;,
            &apos;description&apos; =&gt; &apos;台灣最有溫度的團購平台——找好物、揪好友、一起買更划算！&apos;,
            &apos;features&apos; =&gt; [
                [&apos;icon&apos; =&gt; &apos;🛒&apos;, &apos;title&apos; =&gt; &apos;輕鬆開團&apos;, &apos;desc&apos; =&gt; &apos;三步驟建立團購，分享連結就能揪人&apos;],
                [&apos;icon&apos; =&gt; &apos;👥&apos;, &apos;title&apos; =&gt; &apos;揪團省更多&apos;, &apos;desc&apos; =&gt; &apos;人數越多折扣越大，好康大家一起享&apos;],
                [&apos;icon&apos; =&gt; &apos;🔔&apos;, &apos;title&apos; =&gt; &apos;到貨通知&apos;, &apos;desc&apos; =&gt; &apos;成團、付款、出貨，每一步都即時通知你&apos;],
            ],
        ]);
    }
}
```

### Step 2：建立 Layout

建立 `resources/views/layouts/app.blade.php`：

```html
&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;zh-TW&quot;&gt;
  &lt;head&gt;
    &lt;meta charset=&quot;UTF-8&quot; /&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot; /&gt;
    &lt;title&gt;@yield(&apos;title&apos;, &apos;揪好買&apos;) | 揪好買 JiuHaoMai&lt;/title&gt;
    &lt;style&gt;
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      body {
        font-family: -apple-system, &apos;Noto Sans TC&apos;, sans-serif;
        color: #1a1a2e;
      }
      nav {
        background: #16213e;
        padding: 1rem 2rem;
      }
      nav a {
        color: #e2e2e2;
        text-decoration: none;
        margin-right: 1.5rem;
        font-weight: 500;
      }
      nav a:hover {
        color: #0f9b8e;
      }
      .brand {
        font-size: 1.25rem;
        font-weight: 700;
        color: #0f9b8e;
      }
      main {
        max-width: 800px;
        margin: 0 auto;
        padding: 2rem;
      }
      footer {
        text-align: center;
        padding: 2rem;
        color: #666;
        font-size: 0.875rem;
      }
    &lt;/style&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;nav&gt;
      &lt;a href=&quot;/&quot; class=&quot;brand&quot;&gt;🛒 揪好買&lt;/a&gt;
      &lt;a href=&quot;/about&quot;&gt;關於我們&lt;/a&gt;
    &lt;/nav&gt;

    &lt;main&gt;@yield(&apos;content&apos;)&lt;/main&gt;

    &lt;footer&gt;
      &lt;p&gt;&amp;copy; 2026 揪好買 JiuHaoMai — 用 Laravel 12 打造&lt;/p&gt;
    &lt;/footer&gt;
  &lt;/body&gt;
&lt;/html&gt;
```

### Step 3：建立首頁 View

建立 `resources/views/home.blade.php`：

```blade
@extends(&apos;layouts.app&apos;)
@section(&apos;title&apos;, &apos;首頁&apos;)
@section(&apos;content&apos;)
&lt;div style=&quot;text-align: center; padding: 3rem 0;&quot;&gt;
  &lt;h1 style=&quot;font-size: 2.5rem; margin-bottom: 0.5rem;&quot;&gt;{{ $title }}&lt;/h1&gt;
  &lt;p style=&quot;font-size: 1.25rem; color: #666;&quot;&gt;{{ $description }}&lt;/p&gt;
&lt;/div&gt;

&lt;div style=&quot;display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; margin-top: 2rem;&quot;&gt;
  @foreach($features as $feature)
  &lt;div style=&quot;text-align: center; padding: 2rem; border: 1px solid #eee; border-radius: 12px;&quot;&gt;
    &lt;div style=&quot;font-size: 2.5rem;&quot;&gt;{{ $feature[&apos;icon&apos;] }}&lt;/div&gt;
    &lt;h3 style=&quot;margin: 0.75rem 0 0.5rem;&quot;&gt;{{ $feature[&apos;title&apos;] }}&lt;/h3&gt;
    &lt;p style=&quot;color: #666; font-size: 0.9rem;&quot;&gt;{{ $feature[&apos;desc&apos;] }}&lt;/p&gt;
  &lt;/div&gt;
  @endforeach
&lt;/div&gt;

&lt;div style=&quot;text-align: center; margin-top: 3rem;&quot;&gt;
  &lt;p style=&quot;color: #999;&quot;&gt;🚧 更多功能開發中——跟著這本書一起打造！&lt;/p&gt;
&lt;/div&gt;
@endsection
```

### Step 4：設定路由

編輯 `routes/web.php`：

```php
&lt;?php

use App\Http\Controllers\PageController;
use Illuminate\Support\Facades\Route;

Route::get(&apos;/&apos;, [PageController::class, &apos;home&apos;]);
```

### Step 5：啟動！

```bash
php artisan serve
```

打開 `http://localhost:8000`，你應該會看到：

- 頂部導航列，有「揪好買」品牌名和連結
- 大標題「揪好買 JiuHaoMai」
- 三個功能特色卡片：輕鬆開團、揪團省更多、到貨通知
- 底部版權資訊

恭喜，你的第一個 Laravel 應用正在運行了。

### 剛才發生了什麼？

讓我們追蹤一下這個 request 的旅程（下一章會更深入）：

1. 瀏覽器發送 `GET /` 到 `localhost:8000`
2. Laravel 的 `public/index.php` 接收 request
3. 路由系統查 `routes/web.php`，找到 `GET /` 對應 `PageController@home`
4. `PageController::home()` 執行，準備資料，呼叫 `view(&apos;home&apos;, [...])`
5. Blade 引擎渲染 `resources/views/home.blade.php`（套用 layout）
6. 最終 HTML 回傳給瀏覽器

這六步就是 Laravel 處理每一個 HTTP request 的基本流程。[下一章](/blog/laravel-guide-lifecycle-container-middleware)我們會深入 Request Lifecycle，了解 Middleware 和 Service Container 如何在這個流程中扮演角色。

## 小結：十分鐘後你已經會什麼了

讓我們盤點一下這一章學到的東西：

- **安裝 Laravel**——`composer create-project` 或 `laravel new`，一行搞定
- **目錄結構**——知道 `routes/`、`app/Http/Controllers/`、`resources/views/`、`database/` 各自的職責
- **Artisan CLI**——`serve`、`make:controller`、`route:list`、`tinker`
- **路由系統**——在 `routes/web.php` 定義 URL，對應到 Closure 或 Controller
- **Blade 模板**——`{{ }}` 輸出資料、`@if`/`@foreach` 控制流程、`@extends`/`@yield` 版面繼承
- **環境設定**——`.env` 存放敏感資訊，`config()` 讀取設定，永遠不在 Controller 裡直接呼叫 `env()`
- **實作**——「揪好買」首頁已經跑起來了

[下一章](/blog/laravel-guide-lifecycle-container-middleware)，我們要揭開 Laravel 的「魔法」面紗——Request Lifecycle、Service Container 和 Middleware。聽起來很抽象，但理解這些概念之後，你才能真正駕馭這個框架，而不只是「它 work 了但我不知道為什麼」。</content:encoded><media:content url="https://bobochen.dev/_astro/cover.ffGyDSgY.webp" medium="image"/><category>PHP</category><category>Laravel</category><category>Laravel 12</category><category>Artisan</category><enclosure url="https://bobochen.dev/_astro/cover.ffGyDSgY.webp" length="0" type="image/png"/></item><item><title>PHP 不是你記憶中的樣子：寫給現代開發者的 PHP 快速入門</title><link>https://bobochen.dev/blog/laravel-guide-php-for-modern-developers/</link><guid isPermaLink="true">https://bobochen.dev/blog/laravel-guide-php-for-modern-developers/</guid><description>打破「PHP 是上個時代語言」的偏見。對照 JavaScript、Python、TypeScript，帶現代開發者掌握 PHP 8.4+ 型別系統、Enum、Match Expression、Named Arguments、Property Hooks 與 Composer 套件管理，為 Laravel 12 開發打好基礎。</description><pubDate>Tue, 04 Mar 2025 00:00:00 GMT</pubDate><content:encoded>「PHP？那不是上個時代的語言嗎？」——如果你腦中閃過這個念頭，我完全理解。十年前的 PHP 確實混亂：沒有型別提示、沒有套件管理、一堆 `mysql_*` 全域函式散落各處。那個年代寫 PHP 的體驗，大概跟在泥地裡蓋房子差不多。

但 2026 年的 PHP 8.4+，已經是一門完全不同的語言了。它有嚴格的型別系統、Enum、Named Arguments、Property Hooks、Readonly Properties、Match Expression——這些特性放在任何現代語言裡都不會顯得突兀。2024 年 9 月，Laravel 拿到了 Accel 領投的 5,700 萬美金 A 輪融資，這是 PHP 框架生態系商業價值的最強信號。

這一章我們不會從零教你寫 `echo &quot;Hello World&quot;`。我假設你已經會至少一門程式語言（JavaScript、Python、Go 都行），所以我們用**對照表**的方式快速帶你過一遍 PHP 8.4+ 的核心語法。目標是讀完這章之後，你看得懂 Laravel 原始碼裡的 PHP，也知道怎麼用 Composer 管理套件。準備好了，我們開始。

## 2026 年了，PHP 還值得學嗎？

先看數字說話：

- **71.7%**——根據 W3Techs 2026 年 3 月的統計，全球已知伺服器端語言的網站中，有 71.7% 使用 PHP。遙遙領先第二名的 Ruby（約 6.8%）。
- **42.2%**——根據 W3Techs 2026 年 5 月的統計，所有網站中有 42.2% 跑在 WordPress 上，而 WordPress 是純 PHP 寫的。
- **454,000+**——PHP 的套件管理器 Packagist 上有超過 45 萬個套件，累計安裝次數超過 1,800 億次。
- **$57M**——Laravel 在 2024 年 9 月完成了 Accel 領投的 A 輪融資，估值和商業前景都在成長。

「但 Stack Overflow 調查裡 PHP 用的人不多啊？」那個調查反映的是「誰在填問卷」，不是「誰在跑 production」。PHP 龐大到不需要被討論——它就是在那裡，安靜地服務著全世界七成以上的網站。

不過上面這幾個數字我得幫你補一下脈絡，不然會誤導你。那 71.7% 跟 42.2% 主要是「存量」——絕大多數是早就架好、跑了很多年的 WordPress 站和老站，不代表 2026 年的新專案都在選 PHP。而 Stack Overflow 那題我也不想全推給「誰在填問卷」：PHP 在「開發者想不想用」這個指標上確實偏弱，薪資中位數比不上 Go/Rust，AI、資料工程那一塊基本上是 Python 的天下，你拿 PHP 去硬擠不會太愉快。我的結論是：別把高市占當成「PHP 是新專案首選」的證據；但如果你要做的是 Web 後端、SaaS、接案、CMS，PHP 到今天仍然是非常務實、CP 值很高的選擇。適不適合，看你要解的是哪種問題，自己判斷比我幫你下定論好。

更重要的是，**現代 PHP 和你記憶中的 PHP 完全不同**。PHP 的每一個大版本都帶來實質的語法改進：

| 版本    | 發布年份 | 關鍵特性                                                                              |
| ------- | -------- | ------------------------------------------------------------------------------------- |
| PHP 8.0 | 2020     | Named Arguments、Match Expression、Union Types、Nullsafe Operator                     |
| PHP 8.1 | 2021     | Enums、Fibers（協程）、Readonly Properties、Intersection Types                        |
| PHP 8.2 | 2022     | Readonly Classes、Disjunctive Normal Form Types、`true`/`false`/`null` 獨立型別       |
| PHP 8.3 | 2023     | Typed Class Constants、`#[\Override]`、`json_validate()`                              |
| PHP 8.4 | 2024     | **Property Hooks**、Asymmetric Visibility、`array_find()`/`array_any()`/`array_all()` |
| PHP 8.5 | 2025     | Pipe Operator `\|&gt;`、URI Extension、`array_first()`/`array_last()`、Clone With（目前最新穩定版） |

如果你的 PHP 印象停留在 5.x 時代，請直接跳到 8.4。那是一門不同的語言。

&gt; **平反歸平反，現代 PHP 還是有幾個老問題你早晚會踩到：**
&gt;
&gt; - 標準函式庫 API 不一致——`in_array(needle, haystack)` 跟 `str_contains(haystack, needle)` 參數順序剛好相反，函式名有的加底線（`str_replace`）有的駝峰（`array_map`），這個亂象到 8.5 都還在。IDE 補全會救你大半，但別期待它像 Python 標準庫那樣整齊。
&gt; - 沒有原生泛型——你寫不出 `Collection&lt;User&gt;` 讓引擎幫你檢查型別，只能靠 PHPStan / Psalm 加註解，或在 docblock 標 array 形狀來補。
&gt; - `==` 弱比較跟自動型別轉換的歷史包袱還在，習慣一律用 `===` 就能避掉大半的雷。
&gt; - 生態系仍然新舊混雜——Google 隨手搜到的教學一大半是 PHP 5 的 procedural 老寫法，跟著抄你會學到一身壞習慣。
&gt;
&gt; 我把短板攤出來不是要打臉前面的平反，剛好相反——願意承認缺點的平反，才是有說服力的平反。

## 環境建置：安裝 PHP 8.4+ 與 Composer

### macOS

最簡單的方式是用 Homebrew：

```bash
brew install php
php -v   # 確認版本 &gt;= 8.4
```

Homebrew 預設安裝最新穩定版。如果你需要特定版本：

```bash
brew install php@8.4
```

### Linux (Ubuntu/Debian)

```bash
sudo apt update
sudo apt install software-properties-common
sudo add-apt-repository ppa:ondrej/php
sudo apt update
sudo apt install php8.4 php8.4-cli php8.4-mbstring php8.4-xml php8.4-curl php8.4-zip
php -v
```

### Windows

推薦使用 [Laragon](https://laragon.org/)——一鍵安裝 PHP + Composer + MySQL + Nginx，比手動設定 WAMP 省心太多。

### 安裝 Composer

Composer 是 PHP 的套件管理器，等同於 npm（Node.js）或 pip（Python）。Laravel 本身就是一個 Composer 套件。

```bash
# macOS / Linux
curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer
composer --version
```

安裝完 PHP 和 Composer，你就準備好了。後面的章節會用 Composer 來安裝 Laravel。

### 確認安裝：你的第一行 PHP

建立一個 `hello.php` 檔案：

```php
&lt;?php

declare(strict_types=1);

echo &quot;Hello from PHP &quot; . PHP_VERSION . PHP_EOL;
```

執行它：

```bash
php hello.php
# Hello from PHP 8.4.x
```

`declare(strict_types=1)` 是現代 PHP 的第一行——它強制啟用嚴格型別檢查。為節省篇幅，後續範例省略這兩行（`&lt;?php` 與 `declare(strict_types=1)`），但你實際寫檔案時都要加上。

## 型別系統：PHP 也有 Type Hints 了

PHP 從 7.0 開始引入型別宣告，到了 8.4 已經相當完整。如果你從 TypeScript 或 Python type hints 來的，會覺得很熟悉：

### 基本型別宣告

```php
&lt;?php

declare(strict_types=1);

function add(int $a, int $b): int
{
    return $a + $b;
}

echo add(3, 4);    // 7
echo add(&apos;3&apos;, 4);  // ❌ TypeError（strict_types 開啟時）
```

### 可為 null 的型別

```php
function findUser(int $id): ?User
{
    // 回傳 User 或 null
    return User::find($id);
}
```

### Union Types（PHP 8.0+）

```php
function formatId(int|string $id): string
{
    return &quot;ID: {$id}&quot;;
}

formatId(42);      // ✅
formatId(&apos;abc&apos;);   // ✅
formatId(3.14);    // ❌ TypeError
```

### Intersection Types（PHP 8.1+）

```php
function process(Countable&amp;Iterator $items): void
{
    // $items 必須同時實作 Countable 和 Iterator
    foreach ($items as $item) {
        // ...
    }
}
```

### 跨語言對照

| 概念      | PHP 8.4                          | TypeScript                    | Python                        |
| --------- | -------------------------------- | ----------------------------- | ----------------------------- |
| 基本型別  | `int`, `string`, `float`, `bool` | `number`, `string`, `boolean` | `int`, `str`, `float`, `bool` |
| 陣列      | `array`                          | `Array&lt;T&gt;` / `T[]`            | `list[T]`                     |
| 可為 null | `?string`                        | `string \| null`              | `Optional[str]`               |
| Union     | `int\|string`                    | `number \| string`            | `int \| str`                  |
| 回傳型別  | `: int`                          | `: number`                    | `-&gt; int`                      |
| 無回傳值  | `: void`                         | `: void`                      | `-&gt; None`                     |
| 嚴格模式  | `declare(strict_types=1)`        | 預設嚴格                      | mypy 靜態檢查                 |

&gt; **重點：** PHP 的型別檢查是**執行期**（runtime）的，不像 TypeScript 只在編譯期。如果型別不符，PHP 會直接丟出 `TypeError`。

## 現代語法快速對照：PHP vs JavaScript vs Python

如果你已經會 JS 或 Python，下面這張表讓你五分鐘看懂 PHP 語法：

### 變數與常數

```php
// PHP
$name = &apos;Bobo&apos;;              // 變數用 $ 開頭
$age = 35;
const TAX_RATE = 0.05;       // 常數
define(&apos;APP_NAME&apos;, &apos;揪好買&apos;); // 另一種常數定義方式
```

```javascript
// JavaScript
const name = &apos;Bobo&apos;;
let age = 35;
const TAX_RATE = 0.05;
```

```python
# Python
name = &apos;Bobo&apos;
age = 35
TAX_RATE = 0.05
```

### 字串插值

```php
// PHP —— 雙引號才能插值，單引號不行
$name = &apos;Bobo&apos;;
echo &quot;Hello, {$name}!&quot;;    // Hello, Bobo!
echo &apos;Hello, {$name}!&apos;;    // Hello, {$name}!（原樣輸出）
```

```javascript
// JavaScript
console.log(`Hello, ${name}!`); // 反引號
```

### 陣列（Array）

PHP 的 array 同時扮演了 JS 的 Array 和 Object 的角色：

```php
// 索引陣列（像 JS Array）
$fruits = [&apos;apple&apos;, &apos;banana&apos;, &apos;cherry&apos;];
echo $fruits[0]; // apple

// 關聯陣列（像 JS Object / Python dict）
$user = [
    &apos;name&apos; =&gt; &apos;Bobo&apos;,
    &apos;age&apos; =&gt; 35,
    &apos;city&apos; =&gt; &apos;Taipei&apos;,
];
echo $user[&apos;name&apos;]; // Bobo
```

```javascript
// JavaScript
const fruits = [&apos;apple&apos;, &apos;banana&apos;, &apos;cherry&apos;];
const user = { name: &apos;Bobo&apos;, age: 35, city: &apos;Taipei&apos; };
```

```python
# Python
fruits = [&apos;apple&apos;, &apos;banana&apos;, &apos;cherry&apos;]
user = {&apos;name&apos;: &apos;Bobo&apos;, &apos;age&apos;: 35, &apos;city&apos;: &apos;Taipei&apos;}
```

### 條件判斷

```php
// PHP —— 幾乎跟 JS/C 一樣
if ($age &gt;= 18) {
    echo &apos;成年&apos;;
} elseif ($age &gt;= 12) {
    echo &apos;青少年&apos;;
} else {
    echo &apos;兒童&apos;;
}
```

&gt; **注意：** PHP 用 `elseif`（連在一起），不是 Python 的 `elif` 也不是 JS 的 `else if`（雖然 `else if` 分開寫也能用）。

### 迴圈

```php
// foreach —— PHP 裡最常用的迴圈
$items = [&apos;蔥油餅&apos;, &apos;雞排&apos;, &apos;珍奶&apos;];

foreach ($items as $item) {
    echo $item . PHP_EOL;
}

// 帶 key 的 foreach（像 Python 的 enumerate）
foreach ($items as $index =&gt; $item) {
    echo &quot;{$index}: {$item}&quot; . PHP_EOL;
}

// 關聯陣列的 foreach
$prices = [&apos;雞排&apos; =&gt; 70, &apos;珍奶&apos; =&gt; 50];
foreach ($prices as $name =&gt; $price) {
    echo &quot;{$name} = \${$price}&quot; . PHP_EOL;
}
```

### 函式

```php
// PHP
function greet(string $name, string $greeting = &apos;你好&apos;): string
{
    return &quot;{$greeting}, {$name}!&quot;;
}

echo greet(&apos;Bobo&apos;);           // 你好, Bobo!
echo greet(&apos;Bobo&apos;, &apos;哈囉&apos;);   // 哈囉, Bobo!
```

## Named Arguments、Enums、Match Expression

這三個是從 PHP 8.0/8.1 開始的殺手級特性。

### Named Arguments（PHP 8.0+）

不用再數參數順序了：

```php
function createProduct(
    string $name,
    int $price,
    string $category = &apos;其他&apos;,
    bool $isActive = true,
): array {
    return compact(&apos;name&apos;, &apos;price&apos;, &apos;category&apos;, &apos;isActive&apos;);
}

// 傳統呼叫——你得記住每個位置
createProduct(&apos;雞排&apos;, 70, &apos;小吃&apos;, true);

// Named Arguments——清楚明瞭
createProduct(
    name: &apos;雞排&apos;,
    price: 70,
    category: &apos;小吃&apos;,
);
// isActive 用預設值 true，不用特別傳

// 還可以跳過中間的參數
createProduct(name: &apos;珍奶&apos;, price: 50, isActive: false);
```

&gt; **JS/Python 開發者注意：** Python 一直有 keyword arguments，JS 則是用解構物件來模擬。PHP 的 Named Arguments 是語言原生支援，IDE 自動補全很方便。

### Enums（PHP 8.1+）

終於有了原生 Enum，不用再 `const STATUS_ACTIVE = 1` 這樣土法煉鋼：

```php
// 基本 Enum
enum OrderStatus
{
    case Pending;
    case Confirmed;
    case Shipped;
    case Delivered;
    case Cancelled;
}

function updateOrder(int $orderId, OrderStatus $status): void
{
    // $status 只能是上面五個值之一，型別安全
}

updateOrder(1, OrderStatus::Confirmed);  // ✅
updateOrder(1, &apos;confirmed&apos;);             // ❌ TypeError

// Backed Enum（對應資料庫值）
enum OrderStatus: string
{
    case Pending = &apos;pending&apos;;
    case Confirmed = &apos;confirmed&apos;;
    case Shipped = &apos;shipped&apos;;
    case Delivered = &apos;delivered&apos;;
    case Cancelled = &apos;cancelled&apos;;
}

// 從資料庫值反查
$status = OrderStatus::from(&apos;confirmed&apos;);  // OrderStatus::Confirmed
$status = OrderStatus::tryFrom(&apos;invalid&apos;); // null（不會拋例外）

// Enum 還能有方法
enum OrderStatus: string
{
    case Pending = &apos;pending&apos;;
    case Confirmed = &apos;confirmed&apos;;
    case Shipped = &apos;shipped&apos;;
    case Delivered = &apos;delivered&apos;;
    case Cancelled = &apos;cancelled&apos;;

    public function label(): string
    {
        return match ($this) {
            self::Pending =&gt; &apos;待確認&apos;,
            self::Confirmed =&gt; &apos;已成團&apos;,
            self::Shipped =&gt; &apos;已出貨&apos;,
            self::Delivered =&gt; &apos;已送達&apos;,
            self::Cancelled =&gt; &apos;已取消&apos;,
        };
    }
}

echo OrderStatus::Confirmed-&gt;label(); // 已成團
```

&gt; 在我們後面打造「揪好買」團購平台時，Enum 會大量用在訂單狀態、團購狀態等地方。

### Match Expression（PHP 8.0+）

`match` 是 `switch` 的現代替代品——更簡潔、型別安全、回傳值：

```php
// 傳統 switch（容易忘記 break）
switch ($status) {
    case &apos;pending&apos;:
        $label = &apos;待確認&apos;;
        break;
    case &apos;confirmed&apos;:
        $label = &apos;已成團&apos;;
        break;
    default:
        $label = &apos;未知&apos;;
        break;
}

// match（更優雅）
$label = match ($status) {
    &apos;pending&apos; =&gt; &apos;待確認&apos;,
    &apos;confirmed&apos; =&gt; &apos;已成團&apos;,
    &apos;shipped&apos;, &apos;delivered&apos; =&gt; &apos;已出貨/到貨&apos;,  // 多值對應
    default =&gt; &apos;未知&apos;,
};
```

`match` vs `switch` 的關鍵差異：

- `match` 用嚴格比較（`===`），不會有 `0 == &apos;foo&apos;` 這種坑
- `match` 是表達式，可以直接賦值
- 沒有 `break`，不會 fall-through
- 沒匹配到且沒 `default` 會拋 `UnhandledMatchError`

## 現代 PHP 的類別語法：Constructor Promotion 到 Property Hooks（8.0–8.4）

### Constructor Promotion（PHP 8.0+）

這大概是讓 PHP class 寫法最精簡的一個特性：

```php
// 傳統寫法（囉嗦）
class Product
{
    public string $name;
    public int $price;
    public string $category;

    public function __construct(string $name, int $price, string $category)
    {
        $this-&gt;name = $name;
        $this-&gt;price = $price;
        $this-&gt;category = $category;
    }
}

// Constructor Promotion（一行搞定）
class Product
{
    public function __construct(
        public string $name,
        public int $price,
        public string $category,
    ) {}
}

$product = new Product(&apos;雞排&apos;, 70, &apos;小吃&apos;);
echo $product-&gt;name; // 雞排
```

&gt; **TypeScript 開發者會覺得很像：** `constructor(public name: string)` 的概念是一樣的。

### Readonly Properties（PHP 8.1+）

設定一次就不能改，適合用在值物件和 DTO：

```php
class GroupBuy
{
    public function __construct(
        public readonly string $title,
        public readonly int $minParticipants,
        public readonly \DateTimeImmutable $deadline,
    ) {}
}

$group = new GroupBuy(&apos;辦公室零食團&apos;, 5, new \DateTimeImmutable(&apos;2026-07-01&apos;));
echo $group-&gt;title;         // 辦公室零食團
$group-&gt;title = &apos;改名&apos;;     // ❌ Error: Cannot modify readonly property
```

### Property Hooks（PHP 8.4+）

這是 PHP 8.4 最重磅的特性——類似 C# 的 getter/setter 或 Kotlin 的 property delegation：

```php
class Temperature
{
    public float $celsius {
        set(float $value) {
            if ($value &lt; -273.15) {
                throw new \ValueError(&apos;低於絕對零度&apos;);
            }
            $this-&gt;celsius = $value;
        }
    }

    public float $fahrenheit {
        get =&gt; $this-&gt;celsius * 9 / 5 + 32;
        set(float $value) =&gt; $this-&gt;celsius = ($value - 32) * 5 / 9;
    }
}

$temp = new Temperature();
$temp-&gt;celsius = 100;
echo $temp-&gt;fahrenheit;  // 212

$temp-&gt;fahrenheit = 32;
echo $temp-&gt;celsius;     // 0
```

### Asymmetric Visibility（PHP 8.4+）

讀取和寫入可以有不同的存取權限：

```php
class User
{
    public function __construct(
        public private(set) string $name,    // 外部可讀，只有內部可寫
        public protected(set) string $email, // 外部可讀，子類別可寫
    ) {}
}

$user = new User(&apos;Bobo&apos;, &apos;bobo@example.com&apos;);
echo $user-&gt;name;        // ✅ 可以讀
$user-&gt;name = &apos;改名&apos;;    // ❌ Error: Cannot modify private(set) property
```

## Arrow Functions 與 Closure

### Closure（匿名函式）

PHP 的 Closure 跟 JS 的匿名函式類似，但有一個關鍵差異——**不會自動捕獲外部變數**，必須用 `use` 明確宣告：

```php
$taxRate = 0.05;

// PHP Closure——必須用 use 捕獲外部變數
$calculateTax = function (int $price) use ($taxRate): float {
    return $price * $taxRate;
};

echo $calculateTax(1000); // 50.0
```

```javascript
// JavaScript——自動捕獲（closure）
const taxRate = 0.05;
const calculateTax = (price) =&gt; price * taxRate;
```

&gt; 這是很多 JS 開發者初學 PHP 最困惑的地方。PHP 的設計哲學是「顯式優於隱式」，`use` 讓你明確知道 closure 依賴了哪些外部變數。

### Arrow Functions（PHP 7.4+）

短語法，自動捕獲外部變數，只能有單一表達式：

```php
$taxRate = 0.05;

// Arrow Function —— 自動捕獲，不用 use
$calculateTax = fn(int $price): float =&gt; $price * $taxRate;

echo $calculateTax(1000); // 50.0

// 常見用途：陣列操作
$prices = [100, 200, 300];
$withTax = array_map(fn($p) =&gt; $p * (1 + $taxRate), $prices);
// [105.0, 210.0, 315.0]
```

### 陣列高階函式

PHP 8.4 新增了更多實用的陣列函式，讓函式風格程式設計更方便：

```php
$products = [
    [&apos;name&apos; =&gt; &apos;雞排&apos;, &apos;price&apos; =&gt; 70, &apos;active&apos; =&gt; true],
    [&apos;name&apos; =&gt; &apos;珍奶&apos;, &apos;price&apos; =&gt; 50, &apos;active&apos; =&gt; true],
    [&apos;name&apos; =&gt; &apos;臭豆腐&apos;, &apos;price&apos; =&gt; 60, &apos;active&apos; =&gt; false],
];

// array_find（PHP 8.4）—— 找到第一個符合條件的
$cheap = array_find($products, fn($p) =&gt; $p[&apos;price&apos;] &lt; 55);
// [&apos;name&apos; =&gt; &apos;珍奶&apos;, &apos;price&apos; =&gt; 50, &apos;active&apos; =&gt; true]

// array_any / array_all（PHP 8.4）
$hasInactive = array_any($products, fn($p) =&gt; !$p[&apos;active&apos;]);  // true
$allActive = array_all($products, fn($p) =&gt; $p[&apos;active&apos;]);     // false

// 經典的 array_map / array_filter
$activeNames = array_map(
    fn($p) =&gt; $p[&apos;name&apos;],
    array_filter($products, fn($p) =&gt; $p[&apos;active&apos;]),
);
// [&apos;雞排&apos;, &apos;珍奶&apos;]
```

&gt; **JS 開發者注意：** PHP 的 `array_filter` 和 `array_map` 是**全域函式**而非陣列方法，參數順序跟 JS 不同（`array_map(callback, array)` vs `array.map(callback)`）。這是 PHP 最讓人不適應的地方之一，但習慣了就好。Laravel 的 Collection 類別會幫你解決這個問題——到[第四章 Eloquent ORM](/blog/laravel-guide-eloquent-orm-models/)你就會看到。

## Composer：PHP 的 npm / pip

Composer 是 PHP 生態系的基石。截至 2026 年，Packagist 上有超過 45 萬個套件，累計安裝超過 1,800 億次。Laravel 本身就是透過 Composer 安裝的。

### 基本操作

```bash
# 建立新專案（從現有套件）
composer create-project laravel/laravel my-project

# 安裝相依套件（等同 npm install）
composer install

# 新增套件（等同 npm install package-name）
composer require guzzlehttp/guzzle

# 新增開發用套件（等同 npm install --save-dev）
composer require --dev pestphp/pest

# 更新所有套件
composer update

# 移除套件
composer remove guzzlehttp/guzzle
```

### composer.json vs package.json

| 概念     | Composer (PHP)            | npm (Node.js)       | pip (Python)       |
| -------- | ------------------------- | ------------------- | ------------------ |
| 設定檔   | `composer.json`           | `package.json`      | `pyproject.toml`   |
| Lock 檔  | `composer.lock`           | `package-lock.json` | `requirements.txt` |
| 套件目錄 | `vendor/`                 | `node_modules/`     | `site-packages/`   |
| 執行指令 | `composer run`            | `npm run`           | —                  |
| 全域安裝 | `composer global require` | `npm install -g`    | `pip install`      |

一個典型的 `composer.json`：

&gt; **版本說明**：本系列以 Laravel 12 為基準。Laravel 13 已於 2026 年 3 月 17 日正式發布，若你跟著做時裝到 13，核心概念完全相同，只需將 `^12.0` 改為 `^13.0`，其餘無需調整。

```json
{
  &quot;name&quot;: &quot;bobo/jiu-hao-mai&quot;,
  &quot;description&quot;: &quot;揪好買——台灣團購平台&quot;,
  &quot;type&quot;: &quot;project&quot;,
  &quot;require&quot;: {
    &quot;php&quot;: &quot;^8.4&quot;,
    &quot;laravel/framework&quot;: &quot;^12.0&quot;
  },
  &quot;require-dev&quot;: {
    &quot;pestphp/pest&quot;: &quot;^3.0&quot;
  },
  &quot;autoload&quot;: {
    &quot;psr-4&quot;: {
      &quot;App\\&quot;: &quot;app/&quot;
    }
  }
}
```

### PSR-4 自動載入

PHP 不像 JS 有 `import`/`require`，也不像 Python 有 `import`。PHP 的模組系統是透過 **Composer 的 PSR-4 autoloading** 來實現的：

`app/Models/Product.php`：

```php
// 不用手動 require 每一個檔案
// Composer 會根據 namespace 自動找到對應的檔案

namespace App\Models;

class Product
{
    // ...
}
```

`app/Http/Controllers/ProductController.php`：

```php
namespace App\Http\Controllers;

use App\Models\Product;  // 自動載入 app/Models/Product.php

class ProductController
{
    public function index()
    {
        $products = Product::all();
    }
}
```

規則很簡單：**Namespace 對應目錄路徑**。`App\Models\Product` 對應 `app/Models/Product.php`。Composer 的 autoloader 幫你處理所有的檔案載入，你只需要寫 `use`。

&gt; 這跟 Python 的模組系統很像——`import app.models.product` 對應 `app/models/product.py`。差別是 PHP 用 `\` 而不是 `.`，且 namespace 宣告是手動的。

## PSR 標準：PHP 社群的共識

PSR（PHP Standards Recommendations）是 PHP-FIG（Framework Interoperability Group）制定的標準，確保不同框架和套件之間能互通。你不需要全部背下來，但知道這幾個最常見的就夠：

| 標準   | 名稱                   | 一句話說明                                            | 對應其他生態系            |
| ------ | ---------------------- | ----------------------------------------------------- | ------------------------- |
| PSR-1  | Basic Coding Standard  | 基本的命名規範（類別用 PascalCase、方法用 camelCase） | ESLint 基本規則           |
| PSR-4  | Autoloading Standard   | Namespace 對應目錄結構的自動載入規則                  | Node.js module resolution |
| PSR-12 | Extended Coding Style  | 程式碼風格規範（縮排、大括號位置等）                  | Prettier / Black          |
| PSR-7  | HTTP Message Interface | HTTP Request/Response 的標準介面                      | Express 的 req/res        |
| PSR-11 | Container Interface    | DI Container 的標準介面                               | —                         |

實務上，Laravel 完全遵循 PSR-4 和 PSR-12。你安裝一個叫 [Laravel Pint](https://laravel.com/docs/pint) 的工具，就能一鍵格式化程式碼：

```bash
# 安裝（Laravel 12 內建）
# 格式化所有 PHP 檔案
./vendor/bin/pint
```

就像 JavaScript 有 Prettier、Python 有 Black——PHP 有 Pint。風格爭論到此結束。

## 小結：準備好進入 Laravel 了

這一章我們用最快的速度帶你走過了現代 PHP 的核心：

- **型別系統**——`int`、`string`、Union Types、Intersection Types，執行期檢查
- **現代語法**——Named Arguments、Enums、Match Expression、Constructor Promotion
- **PHP 8.4 新特性**——Property Hooks、Asymmetric Visibility、`array_find()`/`array_any()`/`array_all()`
- **Closure 與 Arrow Functions**——注意 `use` 關鍵字的差異
- **Composer**——套件管理、PSR-4 自動載入
- **PSR 標準**——社群共識、Pint 格式化工具

如果你是 JavaScript 開發者，最需要適應的是：

1. 變數要加 `$`
2. Closure 要用 `use` 捕獲外部變數
3. 陣列操作是全域函式而非方法（但 Laravel Collection 會解決這個問題）
4. Namespace 用 `\` 不是 `.` 或 `/`

如果你是 Python 開發者，最需要適應的是：

1. 每行結尾要加 `;`
2. 大括號 `{}` 而不是縮排
3. `$` 開頭的變數
4. `-&gt;` 而不是 `.` 存取屬性和方法

這些都是語法糖衣的差異，核心概念（型別、函式、類別、模組化）是通用的。你已經會程式設計了，只是在學一門新的方言。

[下一章，我們正式進入 Laravel 12](/blog/laravel-guide-setup-first-route/)——從 `composer create-project` 開始，十分鐘內讓「揪好買」的專案骨架跑起來。</content:encoded><media:content url="https://bobochen.dev/_astro/cover.BUwu01U0.webp" medium="image"/><category>PHP</category><category>Laravel</category><category>PHP 8.4</category><category>Composer</category><enclosure url="https://bobochen.dev/_astro/cover.BUwu01U0.webp" length="0" type="image/png"/></item></channel></rss>