RESTful API 與 Sanctum:讓 LINE Bot 也能開團
本篇是「PHP/Laravel 完全指南」系列的第 11 / 15 篇。你可以從系列總覽開始閱讀,也可以直接接著看本文。
你的網頁應用做得很好,使用者可以開團、跟團、下單。但現實世界不只有瀏覽器。有人想用手機 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。要啟用它,跑一行指令:
php artisan install:api
這個指令幫你做了三件事:
- 建立
routes/api.php檔案 - 安裝 Laravel Sanctum(Token 認證套件)
- 執行 Sanctum 需要的 migration(
personal_access_tokens表)
裝完後你會在 routes/api.php 裡看到這樣的起始內容:
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::get('/user', function (Request $request) {
return $request->user();
})->middleware('auth:sanctum');
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 路由
// routes/api.php
use App\Http\Controllers\Api\GroupBuyController;
// 公開端點——任何人都能呼叫
Route::get('/group-buys', [GroupBuyController::class, 'index']);
Route::get('/group-buys/{groupBuy}', [GroupBuyController::class, 'show']);
// 受保護端點——需要 Sanctum Token
Route::middleware('auth:sanctum')->group(function () {
Route::post('/group-buys', [GroupBuyController::class, 'store']);
Route::post('/group-buys/{groupBuy}/join', [GroupBuyController::class, 'join']);
Route::get('/my/orders', [GroupBuyController::class, 'myOrders']);
});
注意我把 API Controller 放在 App\Http\Controllers\Api\ 命名空間下——跟 web Controller 分開,讓結構清楚。
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
php artisan make:resource GroupBuyResource
<?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 [
'id' => $this->id,
'title' => $this->title,
'description' => $this->description,
'target_amount'=> $this->target_amount,
'current_amount'=> $this->current_amount,
'status' => $this->status->value,
'deadline' => $this->deadline->toIso8601String(),
'organizer' => [
'id' => $this->user->id,
'name' => $this->user->name,
],
'participants_count' => $this->participants_count,
'created_at' => $this->created_at->toIso8601String(),
];
}
}
注意幾件事:
- 只暴露需要的欄位——
user_id換成巢狀的organizer物件,更直覺 - 日期用 ISO 8601 格式——
2026-08-10T08:00:00+08:00,前端好 parse - 不暴露
updated_at——API 消費者不需要這個 - Enum 轉成字串——
$this->status->value而不是整個 Enum 物件
在 Controller 裡使用
use App\Http\Resources\GroupBuyResource;
class GroupBuyController extends Controller
{
public function index()
{
$groupBuys = GroupBuy::withCount('participants')
->where('status', 'open')
->latest()
->paginate(15);
return GroupBuyResource::collection($groupBuys);
}
public function show(GroupBuy $groupBuy)
{
$groupBuy->loadCount('participants');
return new GroupBuyResource($groupBuy);
}
}
回傳的 JSON 長這樣
單一資源:
{
"data": {
"id": 42,
"title": "芒果季團購",
"description": "玉井愛文芒果,產地直送",
"target_amount": 30,
"current_amount": 18,
"status": "open",
"deadline": "2026-08-15T23:59:59+08:00",
"organizer": {
"id": 7,
"name": "小陳"
},
"participants_count": 18,
"created_at": "2026-08-01T10:30:00+08:00"
}
}
列表(搭配 Pagination):
{
"data": [
{ "id": 42, "title": "芒果季團購", ... },
{ "id": 41, "title": "阿里山烏龍茶", ... }
],
"links": {
"first": "http://localhost/api/group-buys?page=1",
"last": "http://localhost/api/group-buys?page=5",
"prev": null,
"next": "http://localhost/api/group-buys?page=2"
},
"meta": {
"current_page": 1,
"last_page": 5,
"per_page": 15,
"total": 67
}
}
Laravel 自動把 paginate() 的結果包成帶 links 和 meta 的結構——前端不用自己算分頁。
ResourceCollection:集合層級的客製化
如果你想在集合回傳時加額外資訊(比如統計數據),可以建一個 Collection class:
php artisan make:resource GroupBuyCollection
class GroupBuyCollection extends ResourceCollection
{
public function toArray(Request $request): array
{
return [
'data' => $this->collection,
'stats' => [
'total_open' => GroupBuy::where('status', 'open')->count(),
],
];
}
}
安全提醒: Resource 是你 API 的門面。永遠不要暴露
password、remember_token、內部的pivot資料,或任何使用者不該看到的欄位。養成習慣——在toArray()裡明確列出每一個欄位,而不是用parent::toArray()全部丟出去。
Sanctum Token Authentication
第六章我們用 Session 做瀏覽器的認證。但 API 客戶端(手機 App、LINE Bot、第三方系統)不用瀏覽器,沒有 Cookie 和 Session。它們需要的是 Token-based 認證:每次請求都在 Header 帶一個 token,伺服器驗證這個 token 來識別身份。
Token 認證的流程
1. 使用者用帳號密碼呼叫 /api/login
2. 伺服器驗證成功,回傳一個 token
3. 之後的每個請求,在 Header 帶上:Authorization: Bearer <token>
4. 伺服器看到 token,就知道你是誰
5. 登出時,伺服器把 token 刪掉
建立登入端點
// routes/api.php
use App\Http\Controllers\Api\AuthController;
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);
Route::middleware('auth:sanctum')->group(function () {
Route::post('/logout', [AuthController::class, 'logout']);
Route::get('/user', [AuthController::class, 'user']);
});
<?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->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|email|unique:users',
'password' => 'required|string|min:8|confirmed',
]);
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
]);
$token = $user->createToken('api-token')->plainTextToken;
return response()->json([
'user' => $user->only('id', 'name', 'email'),
'token' => $token,
], 201);
}
public function login(Request $request)
{
$request->validate([
'email' => 'required|email',
'password' => 'required',
]);
$user = User::where('email', $request->email)->first();
if (! $user || ! Hash::check($request->password, $user->password)) {
throw ValidationException::withMessages([
'email' => ['帳號或密碼錯誤。'],
]);
}
$token = $user->createToken('api-token')->plainTextToken;
return response()->json([
'user' => $user->only('id', 'name', 'email'),
'token' => $token,
]);
}
public function logout(Request $request)
{
// 刪除當前使用的 token
$request->user()->currentAccessToken()->delete();
return response()->json(['message' => '已登出']);
}
public function user(Request $request)
{
return response()->json($request->user()->only('id', 'name', 'email', 'role'));
}
}
createToken() 回傳的 plainTextToken 長這樣:1|abc123def456...,前面的數字是 token ID,後面是實際的 token 值。這個值只會在建立時出現一次——資料庫裡存的是 hash 過的版本。告訴你的 API 消費者:拿到 token 就要存好。
Token Abilities(權限)
不是每個 token 都應該有全部權限。例如,LINE Bot 只需要「讀取」的權限,不該能刪除團購:
// 建立有限權限的 token
$token = $user->createToken('line-bot', ['group-buys:read']);
// 建立完整權限的 token
$token = $user->createToken('mobile-app', ['*']);
在路由裡檢查 ability:
Route::middleware(['auth:sanctum', 'ability:group-buys:read'])->group(function () {
Route::get('/group-buys', [GroupBuyController::class, 'index']);
Route::get('/group-buys/{groupBuy}', [GroupBuyController::class, 'show']);
});
Route::middleware(['auth:sanctum', 'ability:group-buys:write'])->group(function () {
Route::post('/group-buys', [GroupBuyController::class, 'store']);
Route::delete('/group-buys/{groupBuy}', [GroupBuyController::class, 'destroy']);
});
需要在 bootstrap/app.php 裡註冊 ability middleware:
use Laravel\Sanctum\Http\Middleware\CheckAbilities;
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'abilities' => CheckAbilities::class, // 必須擁有「全部」列出的 abilities
'ability' => CheckForAnyAbility::class, // 擁有「其中一個」就通過
]);
})
撤銷 Token
// 撤銷當前 token
$request->user()->currentAccessToken()->delete();
// 撤銷所有 token(強制所有裝置登出)
$request->user()->tokens()->delete();
// 撤銷特定 token
$request->user()->tokens()->where('id', $tokenId)->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 裡:
->withMiddleware(function (Middleware $middleware) {
$middleware->throttleApi();
// 等同於 throttle:api,預設 60 次/分鐘
})
自訂 Rate Limiter
在 AppServiceProvider 的 boot() 方法裡定義:
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Http\Request;
public function boot(): void
{
// 已登入使用者:每分鐘 120 次
// 未登入(靠 IP):每分鐘 30 次
RateLimiter::for('api', function (Request $request) {
return $request->user()
? Limit::perMinute(120)->by($request->user()->id)
: Limit::perMinute(30)->by($request->ip());
});
// 登入端點特別嚴格:每分鐘 5 次(防暴力破解)
RateLimiter::for('login', function (Request $request) {
return Limit::perMinute(5)->by($request->ip());
});
}
套用到路由:
Route::post('/login', [AuthController::class, 'login'])
->middleware('throttle:login');
Rate Limit Response Headers
Laravel 自動在回傳的 HTTP Header 裡告訴客戶端剩餘額度:
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
Retry-After: 58 ← 超過限制時,告訴你幾秒後可以再試
自訂超過限制時的回應
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)
->by($request->user()?->id ?: $request->ip())
->response(function (Request $request, array $headers) {
return response()->json([
'message' => '請求太頻繁,請稍後再試。',
], 429, $headers);
});
});
API 版本管理策略
API 一旦發布出去,就有人在用。你改了回傳格式,人家的 App 就壞了。版本管理讓你能安全地升級 API 而不影響現有客戶端。
方法一:URL 前綴(最常見)
// routes/api.php
// v1 版本
Route::prefix('v1')->group(function () {
Route::get('/group-buys', [V1\GroupBuyController::class, 'index']);
});
// v2 版本(未來)
Route::prefix('v2')->group(function () {
Route::get('/group-buys', [V2\GroupBuyController::class, 'index']);
});
呼叫方式: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’t Gonna Need It)——等需要的時候再加。
API 文件化:Scramble
好的 API 沒有文件等於不存在。你的 API 消費者不該來看你的程式碼才知道怎麼呼叫端點。Scramble 是一個能直接從 Laravel 程式碼自動生成 OpenAPI(Swagger)文件的套件——不需要寫任何註解或額外設定。
安裝
composer require dedoc/scramble
就這樣。打開瀏覽器:
http://localhost:8000/docs/api
你會看到一個互動式的 API 文件頁面,自動列出所有端點、參數類型、回傳格式。它是透過分析你的 Route、Controller、FormRequest、Resource 來推斷結構的。
為什麼推薦 Scramble
- 零設定:裝完就能用,不用在程式碼裡寫一堆 annotation
- 自動同步:程式碼改了文件就改了,不會有文件過期的問題
- OpenAPI 標準:輸出的是標準的 OpenAPI 3.x spec,可以匯入 Postman、Insomnia
- 互動式測試:在文件頁面直接送請求測試
如果你需要更細緻的文件控制(加範例、加說明),也可以搭配 PHPDoc 補充:
/**
* 取得團購列表
*
* 回傳所有進行中的團購,支援分頁。
*/
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 產生它,接著就能編輯:
return [
'paths' => ['api/*'], // 哪些路徑要處理 CORS
'allowed_methods' => ['*'], // GET, POST, PUT, DELETE...
'allowed_origins' => [
'http://localhost:3000', // 開發環境前端
'https://jiuhaomai.tw', // 正式環境
],
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0, // Preflight 快取秒數
'supports_credentials' => false,
];
常見踩坑
allowed_origins設成['*']很方便但不安全——正式環境一定要列出明確的 domain- Preflight request(OPTIONS)被 Nginx 擋掉——確保你的 Nginx/Apache 設定允許 OPTIONS 方法通過
- 帶 credentials 時不能用
*——如果supports_credentials是true,allowed_origins不能用萬用字元,必須列明 - 忘記加
api/*到 paths——如果你的 API 路由不在api/底下,CORS 設定不會生效
提醒: CORS 是瀏覽器的機制。手機 App 和 server-to-server 的請求不受 CORS 限制。所以你的 LINE Bot 和後端爬蟲不需要擔心 CORS。
實作:揪好買 API 與 LINE Bot 概念整合
把前面學的全部串起來。我們要幫揪好買建一組完整的 API 端點。
完整的 API 路由
// routes/api.php
use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\GroupBuyController;
// 認證端點
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login'])
->middleware('throttle:login');
// 公開端點
Route::get('/group-buys', [GroupBuyController::class, 'index']);
Route::get('/group-buys/{groupBuy}', [GroupBuyController::class, 'show']);
// 受保護端點
Route::middleware('auth:sanctum')->group(function () {
// 認證
Route::post('/logout', [AuthController::class, 'logout']);
Route::get('/user', [AuthController::class, 'user']);
// 團購操作
Route::post('/group-buys', [GroupBuyController::class, 'store']);
Route::post('/group-buys/{groupBuy}/join', [GroupBuyController::class, 'join']);
// 我的資料
Route::get('/my/orders', [GroupBuyController::class, 'myOrders']);
});
GroupBuyController(API 版)
<?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('participants')
->where('status', 'open')
->when($request->query('search'), function ($query, $search) {
$query->where('title', 'like', "%{$search}%");
})
->latest()
->paginate(15);
return GroupBuyResource::collection($groupBuys);
}
/**
* 取得單一團購詳情
*/
public function show(GroupBuy $groupBuy)
{
$groupBuy->loadCount('participants');
$groupBuy->load('user:id,name');
return new GroupBuyResource($groupBuy);
}
/**
* 建立新團購(需要認證)
*/
public function store(Request $request)
{
$this->authorize('create', GroupBuy::class);
$validated = $request->validate([
'title' => 'required|string|max:255',
'description' => 'required|string',
'target_amount' => 'required|integer|min:2',
'deadline' => 'required|date|after:now',
'price_per_unit'=> 'required|numeric|min:0',
]);
$groupBuy = $request->user()->groupBuys()->create($validated);
return (new GroupBuyResource($groupBuy))
->response()
->setStatusCode(201);
}
/**
* 加入團購
*/
public function join(Request $request, GroupBuy $groupBuy)
{
// 檢查團購是否還開放
if ($groupBuy->status !== 'open') {
return response()->json([
'message' => '此團購已截止或已取消。',
], 422);
}
// 檢查是否已經加入
if ($groupBuy->participants()->where('user_id', $request->user()->id)->exists()) {
return response()->json([
'message' => '你已經加入這個團購了。',
], 422);
}
$validated = $request->validate([
'quantity' => 'required|integer|min:1',
]);
$groupBuy->participants()->attach($request->user()->id, [
'quantity' => $validated['quantity'],
]);
return response()->json([
'message' => '成功加入團購!',
]);
}
/**
* 我的訂單
*/
public function myOrders(Request $request)
{
$orders = $request->user()
->joinedGroupBuys()
->withPivot('quantity')
->withCount('participants')
->latest()
->paginate(15);
return GroupBuyResource::collection($orders);
}
}
ParticipantResource(跟團者資訊)
如果 API 需要回傳參與者列表,也用 Resource 包裝:
<?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 [
'user' => [
'id' => $this->id,
'name' => $this->name,
],
'quantity' => $this->pivot->quantity,
'joined_at' => $this->pivot->created_at?->toIso8601String(),
];
}
}
用 curl 測試
API 不需要瀏覽器就能測試。用 curl 或 httpie 在終端機直接打:
# 註冊
curl -X POST http://localhost:8000/api/register \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"name": "小陳",
"email": "chen@example.com",
"password": "password123",
"password_confirmation": "password123"
}'
# 回傳:{"user":{"id":1,"name":"小陳","email":"chen@example.com"},"token":"1|abc123..."}
# 登入
curl -X POST http://localhost:8000/api/login \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"email": "chen@example.com", "password": "password123"}'
# 查看團購列表(不需要 token)
curl http://localhost:8000/api/group-buys \
-H "Accept: application/json"
# 加入團購(需要 token)
curl -X POST http://localhost:8000/api/group-buys/42/join \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-H "Authorization: Bearer 1|abc123..." \
-d '{"quantity": 2}'
# 如果你裝了 httpie,語法更簡潔
http POST localhost:8000/api/login email=chen@example.com password=password123
http localhost:8000/api/group-buys Authorization:"Bearer 1|abc123..."
重要: 請求 API 時一定要帶
Accept: application/jsonHeader。如果不帶,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 端點的概念實作:
// routes/api.php
Route::post('/line/webhook', [LineWebhookController::class, 'handle']);
<?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->input('events', []);
foreach ($events as $event) {
if ($event['type'] !== 'message') {
continue;
}
$text = $event['message']['text'] ?? '';
$replyToken = $event['replyToken'];
if (str_contains($text, '查團購')) {
$this->replyGroupBuyList($replyToken);
} elseif (str_contains($text, '開團中')) {
$this->replyOpenGroupBuys($replyToken);
}
}
return response()->json(['status' => 'ok']);
}
private function replyGroupBuyList(string $replyToken): void
{
$groupBuys = GroupBuy::where('status', 'open')
->latest()
->take(5)
->get();
$message = "目前開團中的團購:\n\n";
foreach ($groupBuys as $gb) {
$message .= "🛒 {$gb->title}\n";
$message .= " 截止:{$gb->deadline->format('m/d H:i')}\n";
$message .= " 目標:{$gb->current_amount}/{$gb->target_amount} 人\n\n";
}
// 實際整合時透過 LINE Messaging API SDK 回覆
// v7 SDK:$messagingApi->replyMessage(new ReplyMessageRequest([
// 'replyToken' => $replyToken,
// 'messages' => [new TextMessage(['type' => 'text', 'text' => $message])],
// ])); // 舊版 LINEBot::replyText 已淘汰
}
private function replyOpenGroupBuys(string $replyToken): void
{
$count = GroupBuy::where('status', 'open')->count();
$message = "目前有 {$count} 個團購進行中!\n輸入「查團購」看詳細列表。";
// v7 SDK:$messagingApi->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 統一處理:
use Illuminate\Http\Request;
->withExceptions(function (Exceptions $exceptions) {
$exceptions->shouldRenderJsonWhen(function (Request $request) {
return $request->is('api/*') || $request->expectsJson();
});
})
這樣所有 /api/* 路由的例外都會以 JSON 格式回傳:
{
"message": "此團購已截止或已取消。",
"errors": {}
}
驗證錯誤則會自動回傳 422 和欄位明細:
{
"message": "The title field is required.",
"errors": {
"title": ["The title field is required."],
"deadline": ["The deadline must be a date after now."]
}
}
小結:一套後端,多個前端
這一章我們把揪好買從「只服務瀏覽器」升級成「服務所有人」:
API 基礎:
php artisan install:api啟用 API 路由和 Sanctumroutes/api.php是 stateless 的——沒有 Session、沒有 CSRF,靠 Token 認證- API Resource 精準控制 JSON 輸出,搭配 Pagination 自動生成分頁資訊
認證與安全:
- Sanctum Token 認證——
createToken()建立、BearerHeader 攜帶、delete()撤銷 - Token Abilities 做細粒度權限控制
- Rate Limiting 防止 API 被濫用——已登入使用者和匿名使用者各自獨立限制
實務面:
- CORS 設定讓 SPA 前端能跨域存取
- Scramble 自動生成 API 文件,零設定
- LINE Bot webhook 概念——同一個 Laravel 專案服務所有消費者
- API 版本管理 YAGNI——等需要時再加
現在揪好買有了完整的 API 端點,手機 App 可以呼叫、LINE Bot 可以整合、第三方系統可以串接。但平台越來越大,開團主需要看統計報表,管理員需要後台管理介面,團購列表開始變慢——下一章,我們要用 Filament 3 快速建立管理後台,並深入 Eloquent 進階查詢技巧,解決真實世界的效能問題。