iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0
Modern Web

我推的Laravel S2!系列 第 15

我推的Laravel S2|Day 14:RESTful API 設計:REST 原則、實例、Resource/Collection、JSON:API

  • 分享至 

  • xImage
  •  

一句話破題

同一份 Post 資料,昨天是回傳給瀏覽器的 HTML,今天要變成給程式讀的 JSON。REST 的核心概念二十幾年沒變過,Laravel 這邊唯一的大更新是 13 版終於內建了 JSON:API 規範的原生支援——過去這是要靠第三方套件才能做到的事。

前情提要

Day 13 我們把 PostController 寫成一個回傳 Blade 畫面的傳統網頁應用。今天把同一組資源改造成一套 RESTful API——REST 的核心概念完全沒過時,額外補上 Laravel 13 新增的 JSON:API 原生支援。

REST 是什麼:六大限制複習

REST(Representational State Transfer)出自 Roy Fielding 2000 年的博士論文,不是一種協定或框架,而是一套設計風格的限制條件:

  1. Client-Server:前後端關注點分離。
  2. Stateless(無狀態):每個請求要包含伺服器處理所需的全部資訊,伺服器不依賴 session 記住上一次請求的狀態。
  3. Cacheable(可快取):回應要明確標示是否可被快取。
  4. Uniform Interface(統一介面):資源用 URI 識別、用標準 HTTP 動詞操作,這是最核心也最常被討論的一條。
  5. Layered System(分層系統):客戶端不需要知道背後是不是還有負載平衡、快取層等中介層。
  6. Code-On-Demand(選用):伺服器可以視情況傳送可執行程式碼給客戶端(例如 JavaScript),這條是選用的。

實務上多數人講「RESTful API」主要指的是「用資源導向的 URI + 標準 HTTP 動詞」這個慣例,不見得每個系統都嚴格遵守全部六條限制,這是正常且被廣泛接受的做法。

HTTP 狀態碼:API 設計的另一半語言

除了 URI 跟動詞,狀態碼是 REST API 表達語意的另一個核心工具,值得整理成一份清單,之後設計任何端點時可以直接對照:

狀態碼 語意 blog-app 的典型情境
200 OK 請求成功,回應帶有內容 GET /api/postsPATCH /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。

把 PostController 改造成 API

延續 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();
    }
}

需要登入才能寫入:套用 Sanctum 認證中介層

上面的 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 深入。

Resource:控制 API 回傳的 JSON 長相

直接把 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)一併包進回應裡,前端串接分頁元件時不需要額外處理。

條件式欄位:whenLoaded 與 when

實務上很常遇到「這個欄位要不要出現在回應裡,取決於某個條件」的情境,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.phpwithExceptions()(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:Laravel 13 新增的原生支援

如果你的專案需要遵循 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.typedata.attributesdata.relationships 等,type 預設依 Resource 類別名稱推導,PostResource 對應 posts),並處理好資源包含(透過 ?include=author 查詢參數)、稀疏欄位集(sparse fieldsets)這些原本需要手刻大量邏輯的細節。

一個具體對照:兩種 Resource 產出的 JSON 長什麼樣

用同一筆 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 基底類別(JsonResourceJsonApiResource)可以在同一個專案裡並存,依端點需求各自選用。

API 版本控制:先想清楚,別等到要改壞才後悔

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 會談到的「別為了理論上的完美犧牲團隊的實際開發效率」精神的一個具體案例。

常見錯誤與踩雷點

  • API Controller 跟網頁 Controller 混在一起:同一個 PostController 同時處理「回傳 Blade 畫面」跟「回傳 JSON」,容易讓程式碼裡到處都是 if ($request->wantsJson()) 判斷,拆成 Api\PostController 跟一般 PostController 兩個獨立類別更清楚。
  • 回傳 Resource 忘記處理關聯的 N+1 問題PostResource 裡用到 $this->author,如果 Controller 沒有先 with('author') 預先載入,PostResource::collection() 迴圈組裝每一筆資料時都會多發一次查詢,前面提過的 whenLoaded() 是更安全的替代寫法。
  • apiResource() 誤加了 create/edit:這兩個路由是給 HTML 表單頁面用的,API 場景通常不需要,apiResource() 已經自動排除,如果手動列路由要記得比照排除。
  • 401 跟 403 用反:把「沒登入」跟「登入了但沒權限」搞混,會讓 API 消費者難以判斷該引導使用者去登入頁、還是顯示「你沒有權限」的訊息,這兩種情境對前端的處理方式完全不同。
  • API 沒有版本控制策略,第一次要改欄位結構時才發現騎虎難下:這是規劃階段就該想清楚的事,即使 blog-app 現在只有自己的前端在用,養成從第一個版本就走 v1 前綴的習慣,之後真的需要破壞性改版時會輕鬆很多。
  • 沒對寫入端點加流量限制,容易被濫用:Day 7 提過的 RateLimiterapi 這個限流規則預設已經套用在 install:api 產生的路由上,但如果你手動調整過路由群組結構,記得確認 throttle:api 中介層還在生效。

小結

今天把 REST 的核心概念(含容易混淆的狀態碼語意)複習一遍,把 Post 資源改造成一套完整的 API,也補上了條件式欄位輸出、統一錯誤格式、版本控制策略這幾個實務上一定會遇到的設計問題。最後認識了 Eloquent Resource 怎麼控制 JSON 輸出格式,以及 Laravel 13 新增的 JSON:API 原生支援——這是這塊最大的更新,值得在需要遵循嚴謹 API 規範時考慮採用。

名不正則言不順,言不順則事不成 — 《論語・子路》

明日預告

Day 15 進入 Logging 日誌系統,這是這系列另一個穩定章節,把通道設定、Deprecation Warning、自訂 Monolog Handler 整理一遍。


上一篇
我推的Laravel S2|Day 13:Controller 與 Request:Resource Controller、Magic Methods、常用方法
下一篇
我推的Laravel S2|Day 15:Logging 日誌系統
系列文
我推的Laravel S2!18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言