同一份 Post 資料,昨天是回傳給瀏覽器的 HTML,今天要變成給程式讀的 JSON。REST 的核心概念二十幾年沒變過,Laravel 這邊唯一的大更新是 13 版終於內建了 JSON:API 規範的原生支援——過去這是要靠第三方套件才能做到的事。
Day 13 我們把 PostController 寫成一個回傳 Blade 畫面的傳統網頁應用。今天把同一組資源改造成一套 RESTful API——REST 的核心概念完全沒過時,額外補上 Laravel 13 新增的 JSON:API 原生支援。
REST(Representational State Transfer)出自 Roy Fielding 2000 年的博士論文,不是一種協定或框架,而是一套設計風格的限制條件:
實務上多數人講「RESTful API」主要指的是「用資源導向的 URI + 標準 HTTP 動詞」這個慣例,不見得每個系統都嚴格遵守全部六條限制,這是正常且被廣泛接受的做法。
除了 URI 跟動詞,狀態碼是 REST API 表達語意的另一個核心工具,值得整理成一份清單,之後設計任何端點時可以直接對照:
| 狀態碼 | 語意 | blog-app 的典型情境 |
|---|---|---|
| 200 OK | 請求成功,回應帶有內容 | GET /api/posts、PATCH /api/posts/{post} |
| 201 Created | 成功建立新資源 | POST /api/posts |
| 204 No Content | 成功,但沒有內容要回傳 | DELETE /api/posts/{post} |
| 400 Bad Request | 請求本身格式有問題 | 傳了不合法的 JSON body |
| 401 Unauthorized | 未認證(不知道你是誰) | 沒帶 Token 就打需要登入的端點 |
| 403 Forbidden | 已認證,但沒有權限 | 登入了,但想編輯別人的文章 |
| 404 Not Found | 找不到指定的資源 | GET /api/posts/9999(不存在的 ID) |
| 422 Unprocessable Entity | 請求格式正確,但驗證失敗 | 標題留空 |
| 429 Too Many Requests | 觸發流量限制 | Day 7 提過的 RateLimiter |
| 500 Internal Server Error | 伺服器端未預期的錯誤 | 例外沒被正確處理 |
401 跟 403 的差異是最容易混淆的一組:401 代表「我不知道你是誰」(沒登入、Token 過期),403 代表「我知道你是誰,但你不能做這件事」(已登入但權限不足)。好消息是這兩個狀態碼你通常不需要自己判斷——auth 中介層擋下未認證請求時會回 401,Day 13 建立的 PostPolicy 授權失敗時(不論是 $this->authorize()、#[Authorize] 還是 can 中介層)則會拋出 AuthorizationException,Laravel 自動把它轉成 403。
延續 Day 5 提過的做法,先用 Artisan 建立 API 路由檔案(新專案預設沒有這個檔案):
php artisan install:api
這個指令會建立 routes/api.php 並順便安裝 Sanctum(Day 28 會深入 Sanctum 的角色)。定義 API 路由:
// routes/api.php
use App\Http\Controllers\Api\PostController;
Route::apiResource('posts', PostController::class);
apiResource() 跟 Day 7 的 resource() 差別是少了 create/edit 這兩個「顯示表單畫面」用的路由——API 不需要回傳 HTML 表單,只留下五個資料操作方法。
新建一個專門給 API 用的 Controller(跟 Day 13 那個回傳 Blade 畫面的 PostController 分開,放進 Api 子目錄,避免混淆):
// app/Http/Controllers/Api/PostController.php
namespace App\Http\Controllers\Api;
class PostController extends Controller
{
public function index()
{
$posts = Post::with('author')->latest()->paginate(10);
return PostResource::collection($posts);
}
public function store(Request $request)
{
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'body' => ['required', 'string'],
]);
$post = $request->user()->posts()->create($validated);
return new PostResource($post->load('author'));
}
public function show(Post $post)
{
return new PostResource($post->load('author'));
}
public function update(Request $request, Post $post)
{
$validated = $request->validate([
'title' => ['sometimes', 'string', 'max:255'],
'body' => ['sometimes', 'string'],
]);
$post->update($validated);
return new PostResource($post);
}
public function destroy(Post $post)
{
$post->delete();
return response()->noContent();
}
}
上面的 store/update/destroy 目前還沒限制一定要登入才能操作,實務上這是不能忽略的一步:
// routes/api.php
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('posts', PostController::class)->except(['index', 'show']);
});
Route::apiResource('posts', PostController::class)->only(['index', 'show']);
這樣拆分後,文章列表跟單篇檢視(index/show)任何人都能存取,建立/修改/刪除則需要帶有效的 Sanctum Token 才能通過 auth:sanctum 中介層——這對應前面提到的 401 狀態碼:沒帶 Token 或 Token 無效時,這幾個端點會直接回 401,不會讓請求進到 Controller 邏輯。Token 怎麼發放、Sanctum 完整的運作原理留到 Day 28 深入。
直接把 Model 轉成 JSON(return $post;)會把所有欄位(包含你可能不想外露的內部欄位)原封不動吐出去,也很難客製化格式(例如把巢狀的作者資料一起帶出)。這正是 Eloquent Resource 要解決的問題:
php artisan make:resource PostResource
// app/Http/Resources/PostResource.php
class PostResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'body' => $this->body,
'published' => ! is_null($this->published_at),
'published_at' => $this->published_at?->toIso8601String(),
'author' => [
'id' => $this->author->id,
'name' => $this->author->name,
],
];
}
}
PostResource::collection($posts) 會自動把分頁資訊(meta/links)一併包進回應裡,前端串接分頁元件時不需要額外處理。
實務上很常遇到「這個欄位要不要出現在回應裡,取決於某個條件」的情境,Resource 提供幾個專門處理這件事的方法:
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'body' => $this->body,
// 只有關聯真的被預先載入時才輸出,避免意外觸發 N+1 查詢
'author' => new UserResource($this->whenLoaded('author')),
// 依條件決定要不要輸出這個欄位,例如只有作者本人或管理員能看到瀏覽數
'views' => $this->when($request->user()?->id === $this->user_id, $this->views),
// 依條件合併一組欄位(而不是單一欄位)
$this->mergeWhen($request->user()?->isAdmin(), [
'internal_notes' => $this->internal_notes,
'flagged' => $this->flagged,
]),
];
}
whenLoaded() 特別重要——它確保「如果 Controller 忘記 with('author') 預先載入」時,Resource 不會因為存取 $this->author 而觸發一次額外的 N+1 查詢,而是直接省略這個欄位。這比範例最初版本直接寫 $this->author->name(如果沒預先載入,仍然會觸發一次查詢,只是不會報錯)更安全,是正式專案建議採用的寫法。
API 的錯誤回應如果每個端點格式都不一樣,會讓前端串接痛苦不堪。Laravel 對驗證失敗(422)、找不到資源(404)等常見情境,預設已經有一致的 JSON 錯誤格式,但如果你想客製化成專案自己的格式(例如統一包一層 error 物件),可以在 bootstrap/app.php 的 withExceptions()(Day 18 會深入)攔截處理:
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(function (ValidationException $e, Request $request) {
if ($request->is('api/*')) {
return response()->json([
'error' => [
'message' => '輸入資料驗證失敗',
'fields' => $e->errors(),
],
], 422);
}
});
})
這個模式讓你的 API 消費者(前端、行動 App、第三方整合)只需要處理一種錯誤格式,不需要為每種例外類型各自寫解析邏輯——這在團隊有獨立前端工程師、或 API 要對外開放給第三方使用時特別重要。
如果你的專案需要遵循 JSON:API 這個更嚴謹的 API 規範(統一的資源物件格式、關聯資料的 included 區塊、稀疏欄位集、標準化的分頁連結),Laravel 13 起內建 JsonApiResource 這個專門的基底類別,不需要額外裝第三方套件。用 --json-api 旗標產生:
php artisan make:resource PostResource --json-api
產生的類別繼承 Illuminate\Http\Resources\JsonApi\JsonApiResource,用 $attributes/$relationships 兩個屬性宣告要輸出哪些欄位跟關聯——最簡單的情境只要列出欄位名稱,Laravel 會自動從 Model 讀值:
// app/Http/Resources/PostResource.php
use Illuminate\Http\Resources\JsonApi\JsonApiResource;
class PostResource extends JsonApiResource
{
public $attributes = [
'title',
'body',
'created_at',
];
public $relationships = [
'author', // 自動對應 author() 關聯,並自動找出對應的 Resource 類別
];
}
如果需要客製化欄位內容(例如衍生欄位、或某個欄位運算成本較高只想在真的用到時才計算),改寫 toAttributes() 方法,值可以是閉包做延遲運算:
public function toAttributes(Request $request): array
{
return [
'title' => $this->title,
'body' => $this->body,
'is_published' => fn () => $this->published_at !== null,
'created_at' => $this->created_at,
];
}
關聯如果要指定明確的 Resource 類別(而不是讓 Laravel 自動猜),用 key/value 對應:
use App\Http\Resources\UserResource;
public $relationships = [
'author' => UserResource::class,
];
跟一般 Resource 一樣直接從路由/Controller 回傳,或用 Model 的 toResource() 簡寫:
Route::get('/api/posts/{post}', fn (Post $post) => $post->toResource());
回應會自動組成符合 JSON:API 規範的結構(data.type、data.attributes、data.relationships 等,type 預設依 Resource 類別名稱推導,PostResource 對應 posts),並處理好資源包含(透過 ?include=author 查詢參數)、稀疏欄位集(sparse fieldsets)這些原本需要手刻大量邏輯的細節。
用同一筆 Post 資料,兩種 Resource 產出的結構差異一目了然:
// 一般 JsonResource
{
"data": {
"id": 1,
"title": "Laravel 13 上手筆記",
"author": { "id": 1, "name": "作者" }
}
}
// JsonApiResource
{
"data": {
"type": "posts",
"id": "1",
"attributes": {
"title": "Laravel 13 上手筆記",
"created_at": "2026-08-01T12:00:00Z"
},
"relationships": {
"author": {
"data": { "type": "users", "id": "1" }
}
}
},
"included": [
{ "type": "users", "id": "1", "attributes": { "name": "作者" } }
]
}
JSON:API 版本明顯結構化程度更高——關聯資料被拉到獨立的 included 區塊,避免同一筆使用者資料在回傳多篇同作者文章時重複出現在每個 data 物件裡(一般 Resource 如果巢狀輸出關聯,容易造成這種資料重複)。這個差異在資料量大、關聯複雜的 API 裡,能明顯減少回應內容的體積。
要不要導入 JSON:API 規範是一個團隊層級的取捨:如果你的 API 只服務自己的前端(像 blog-app 這樣),前面示範的一般 JsonResource 寫法就很夠用,不需要為了規範而增加複雜度;如果你的 API 需要被多個外部團隊或第三方消費、需要一套業界通用的嚴謹規範,JSON:API 原生支援讓這個選擇的成本大幅降低。兩種 Resource 基底類別(JsonResource、JsonApiResource)可以在同一個專案裡並存,依端點需求各自選用。
blog-app 的 API 一旦有外部消費者(第三方應用、行動 App),任何一次 PostResource 結構的調整都可能讓既有客戶端壞掉。常見的版本控制策略:
// 方式一:URI 前綴(最直觀,這系列示範會採用這個)
Route::prefix('v1')->group(function () {
Route::apiResource('posts', Api\V1\PostController::class);
});
Route::prefix('v2')->group(function () {
Route::apiResource('posts', Api\V2\PostController::class);
});
// 方式二:Header 版本協商(URI 保持乾淨,但客戶端要記得帶對應 Header)
// Accept: application/vnd.blog-app.v2+json
URI 前綴的優點是清楚直觀、方便測試(不同版本的網址本來就不同),缺點是嚴格來說不完全符合「同一份資源應該只有一個 URI」的 REST 精神;Header 版本協商相對更「RESTful」,但實際開發、除錯的便利性較低。實務上多數團隊(包括知名的公開 API,如 Stripe、GitHub)選擇 URI 前綴,因為工程上的可操作性通常比理論上的純粹性更重要——這也是 Day 22 會談到的「別為了理論上的完美犧牲團隊的實際開發效率」精神的一個具體案例。
PostController 同時處理「回傳 Blade 畫面」跟「回傳 JSON」,容易讓程式碼裡到處都是 if ($request->wantsJson()) 判斷,拆成 Api\PostController 跟一般 PostController 兩個獨立類別更清楚。PostResource 裡用到 $this->author,如果 Controller 沒有先 with('author') 預先載入,PostResource::collection() 迴圈組裝每一筆資料時都會多發一次查詢,前面提過的 whenLoaded() 是更安全的替代寫法。apiResource() 誤加了 create/edit:這兩個路由是給 HTML 表單頁面用的,API 場景通常不需要,apiResource() 已經自動排除,如果手動列路由要記得比照排除。blog-app 現在只有自己的前端在用,養成從第一個版本就走 v1 前綴的習慣,之後真的需要破壞性改版時會輕鬆很多。RateLimiter,api 這個限流規則預設已經套用在 install:api 產生的路由上,但如果你手動調整過路由群組結構,記得確認 throttle:api 中介層還在生效。今天把 REST 的核心概念(含容易混淆的狀態碼語意)複習一遍,把 Post 資源改造成一套完整的 API,也補上了條件式欄位輸出、統一錯誤格式、版本控制策略這幾個實務上一定會遇到的設計問題。最後認識了 Eloquent Resource 怎麼控制 JSON 輸出格式,以及 Laravel 13 新增的 JSON:API 原生支援——這是這塊最大的更新,值得在需要遵循嚴謹 API 規範時考慮採用。
名不正則言不順,言不順則事不成 — 《論語・子路》
Day 15 進入 Logging 日誌系統,這是這系列另一個穩定章節,把通道設定、Deprecation Warning、自訂 Monolog Handler 整理一遍。