iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0
Modern Web

我推的Laravel S2!系列 第 8

我推的Laravel S2|Day 07:路由(Route):HTTP 動詞、Resource 路由、命名/群組、每秒級流量限制

  • 分享至 

  • xImage
  •  

一句話破題

路由是使用者請求進到你的應用程式的第一道關卡。核心概念——HTTP 動詞、Resource 路由、路由參數、命名路由——放到 Laravel 13 完全沒有過時,唯一需要動刀的是 RateLimiter 的定義位置:舊寫法放在 RouteServiceProvider,而這個類別在 Day 5 講過的新骨架中已經不存在了。今天一邊複習路由核心概念,一邊把流量限制的寫法更新到現行版本,並補上 Laravel 11 新增的每秒級限流跟簽章網址這類進階主題。

基本路由與 HTTP 動詞

routes/web.php 裡最基本的寫法:

Route::get('/', function () {
    return view('welcome');
});

HTTP 動詞不是隨便選的,它們各自對應語意明確的操作:GET 取得資源、POST 建立新資源、PUT/PATCH 更新資源、DELETE 刪除資源。用對動詞除了語意清楚,也讓瀏覽器/代理伺服器的快取行為、安全性假設(例如瀏覽器不會主動對連結發出 DELETE 請求)符合預期。

PUT 與 PATCH 的差異

這兩個常被搞混:PUT 代表完整更新(理論上要傳完整資料,沒傳到的欄位可能被視為要清空或恢復預設值,且多次執行結果應該一致,也就是「冪等」);PATCH 代表部分更新(只更新有帶到的欄位,例如只想改文章標題,不影響內文)。Laravel 的 Resource 路由兩者都會綁定,實務上多數團隊會統一只用 PATCH 處理部分更新,PUT 較少單獨使用。

HTML 表單怎麼發出 PUT/PATCH/DELETE

瀏覽器的原生 HTML <form> 只支援 GET/POST 兩種方法,這是 HTML 規格本身的限制,不是 Laravel 的問題。Laravel 用「方法偽造(method spoofing)」解決這件事:表單實際上還是用 POST 送出,但附帶一個隱藏欄位告訴 Laravel 這個請求應該被當成哪個動詞處理:

<form method="POST" action="/posts/1">
    @method('PATCH')
    @csrf
    ...
</form>

@method('PATCH') 展開後就是 <input type="hidden" name="_method" value="PATCH">,Laravel 的中介層堆疊會在請求進來時檢查這個欄位,把請求「重新標記」成 PATCH 再交給路由比對。如果你是透過 JavaScript fetch() 或後端對後端呼叫(Day 26 的 HTTP Client),可以直接發出真正的 PATCH/DELETE 請求,不需要這個偽造機制——方法偽造只是為了彌補 HTML 表單本身的限制。

Resource 路由:一行產生七條 CRUD 路由

這是這個系列會反覆用到的寫法。從今天起,blog-app 的文章管理功能,路由就這樣定義(PostController 我們會在 Day 13 實際建立,這裡先把路由骨架寫好):

// routes/web.php
use App\Http\Controllers\PostController;

Route::resource('posts', PostController::class);

這一行等同於展開成七條路由:

HTTP 動詞 URI Controller 方法 用途
GET /posts index 文章列表
GET /posts/create create 顯示新增表單
POST /posts store 儲存新文章
GET /posts/{post} show 顯示單篇文章
GET /posts/{post}/edit edit 顯示編輯表單
PUT/PATCH /posts/{post} update 更新文章
DELETE /posts/{post} destroy 刪除文章

如果只需要其中幾個方法,可以用 only()/except() 縮減:

Route::resource('posts', PostController::class)->only(['index', 'show']);

轉址路由與 View 路由

不需要 Controller 邏輯的簡單情境,有語意更清楚的簡寫:

// 純轉址
Route::redirect('/home', '/dashboard');
Route::permanentRedirect('/old-blog', '/blog');   // 301 永久轉址,SEO 情境會需要區分 301/302

// 直接回傳一個 view,不需要建 Controller 方法
Route::view('/about', 'about');
Route::view('/about', 'about', ['team' => 'blog-app 團隊']);   // 也可以帶資料進去

路由參數與正規化

{post} 這種寫法就是路由參數,通常對應資料表主鍵:

Route::get('/posts/{post}', function (string $postId) {
    return "查看文章 #{$postId}";
});

// 可選參數,記得給預設值
Route::get('/posts/{post?}', function (?string $postId = null) {
    // ...
});

如果某個參數在整個專案中永遠要符合特定格式(例如文章 ID 一定是數字),可以在 AppServiceProvider::boot()Route::pattern() 全域正規化,不用每條路由重複寫 where()

// app/Providers/AppServiceProvider.php
public function boot(): void
{
    Route::pattern('post', '[0-9]+');
}

路由模型綁定:從字串參數到真正的 Model

上面範例裡拿到的都是字串 $postId,實務上幾乎不會停在這一步——Laravel 提供「路由模型綁定」,直接把路由參數換成型別提示,Laravel 會自動幫你查好對應的 Model 實例:

use App\Models\Post;

Route::get('/posts/{post}', function (Post $post) {
    return $post->title;   // $post 已經是查好的 Post 實例,不是字串 ID
});

Laravel 怎麼知道 {post} 這個路由參數對應 Post Model 的哪個欄位?預設是用主鍵(id)查詢,找不到會自動回傳 404(不需要你自己寫 abort(404))。如果你想改成用其他欄位查詢(例如用 slug 取代 id 讓網址更友善),可以用明確綁定或在路由參數上直接指定:

// 方式一:路由定義時直接指定要用哪個欄位
Route::get('/posts/{post:slug}', function (Post $post) {
    return $post->title;
});

// 方式二:在 Model 裡覆寫,讓這個 Model 所有隱式綁定都預設用 slug
// app/Models/Post.php
public function getRouteKeyName(): string
{
    return 'slug';
}

這裡順便提一下 Day 13 會實際建立的 PostController:一旦你的 Controller 方法簽名寫了 Post $post 型別提示(例如 show(Post $post)),路由模型綁定就會自動生效,這也是為什麼這系列從 Day 9 開始,posts 資料表就設計了 slug 欄位——為往後可能想切換到更友善網址的情境預留空間。

命名路由

幫路由取名字,之後在程式碼或 Blade 裡用名字產生網址,比寫死 URL 字串更不容易在改路徑時到處漏改:

Route::get('/posts/{post}', [PostController::class, 'show'])->name('posts.show');
<a href="{{ route('posts.show', $post) }}">閱讀更多</a>

Route::resource() 產生的路由會自動依慣例命名(posts.indexposts.show……),這也是為什麼實務上能用 Resource 路由就盡量用,省下手動命名的功夫。

簽章網址:不需要登入也能安全存取的連結

有些情境需要產生一個「任何人拿到這個連結都能存取一次,但不能被竄改」的網址,典型案例是 Email 驗證連結——使用者收到信時可能還沒登入,卻要能安全地點擊連結驗證信箱。Laravel 提供簽章網址(signed URL)解決這個問題:

use Illuminate\Support\Facades\URL;

$url = URL::temporarySignedRoute(
    'posts.unsubscribe',
    now()->addDays(3),
    ['post' => $post->id]
);

產生出來的網址會附帶一組簽章參數,路由本身可以用 signed 中介層驗證這個簽章是否有效、有沒有被竄改:

Route::get('/posts/{post}/unsubscribe', [PostController::class, 'unsubscribe'])
    ->name('posts.unsubscribe')
    ->middleware('signed');

如果連結裡的任何參數被竄改(例如有人手動把 post 的值改成別篇文章的 ID),簽章驗證會失敗、回傳 403。這個機制在 Day 29 串接 LINE Bot/外部服務時的 Webhook 驗證概念上是類似的思路:不依賴登入狀態,但要確保請求真的是你自己系統產生的

路由群組

把共用屬性(middleware、路徑前綴、名稱前綴)打包套用到多條路由:

Route::middleware(['auth'])->prefix('admin')->name('admin.')->group(function () {
    Route::resource('posts', Admin\PostController::class);
});

上面這段會產生像 admin.posts.index 這樣的路由名稱,並要求先登入才能存取,且 URL 前綴是 /admin/posts

路由群組的屬性可以疊加組合,除了 middleware()/prefix()/name(),還有幾個實務上會用到的:

Route::domain('{account}.blog-app.test')->group(function () {
    // 子網域路由,適合多租戶(multi-tenant)架構,{account} 可以當成路由參數使用
});

Route::controller(PostController::class)->group(function () {
    // 同一個 Controller 的多個方法,不用每條路由都重複寫 [PostController::class, 'xxx']
    Route::get('/posts', 'index');
    Route::get('/posts/{post}', 'show');
});

Route::controller() 這個寫法在你有好幾條路由都指向同一個 Controller、但不想用完整的 Route::resource()(例如只需要其中三、四個方法且命名不遵循 Resource 慣例)時特別方便,比起每行都重複打一次 Controller 類別名稱要精簡。

查看路由列表

開發時最常用的除錯指令之一:

php artisan route:list
php artisan route:list --path=posts   # 只看跟 posts 有關的路由
php artisan route:list -v             # 顯示更多細節(含中介層)
php artisan route:list --except-vendor   # 排除套件本身註冊的路由,只看你自己專案定義的

--except-vendor 在專案裝了不少第三方套件(Sanctum、Telescope 等各自會註冊自己的路由)之後特別實用,不然 route:list 的輸出會被套件路由淹沒,很難一眼找到自己定義的那幾條。

https://ithelp.ithome.com.tw/upload/images/20260812/20163286CUcK0FDwYJ.png

流量限制:從 RouteServiceProvider 搬到 AppServiceProvider

舊寫法是在 app/Providers/RouteServiceProvider.php 裡定義 RateLimiter,但這個 Provider 在 Laravel 11+ 新骨架已經不存在了。現在官方建議的位置是 AppServiceProvider::boot()

// app/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Cache\RateLimiting\Limit;

public function boot(): void
{
    RateLimiter::for('api', function (Request $request) {
        return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
    });
}

定義好之後,套用到路由或路由群組:

Route::middleware(['throttle:api'])->group(function () {
    // ...
});

有一個常見的誤區要特別提醒:不要把 RateLimiter::for() 寫進 bootstrap/app.phpwithMiddleware() 閉包裡。那個時機點應用程式還沒完全啟動,門面(Facade)可能還無法正常運作,實務上會直接噴錯。AppServiceProvider::boot() 才是正確且穩定的位置。

進階限流:多條規則疊加、自訂回應

Limit 支援組合多條規則,同時套用「短時間內不能太頻繁」跟「長時間累計也有上限」兩層限制:

RateLimiter::for('posts-create', function (Request $request) {
    return [
        Limit::perMinute(5)->by($request->user()->id),
        Limit::perDay(50)->by($request->user()->id),
    ];
});

這對 blog-app 這種「使用者可以自行發文」的場景很實用——防止使用者短時間內瘋狂發文洗版,同時也限制每天的總發文量,避免帳號被盜用來大量產生垃圾內容。超出限制時,你也可以自訂回應內容而不是用預設的 429 錯誤頁:

RateLimiter::for('posts-create', function (Request $request) {
    return Limit::perMinute(5)->by($request->user()->id)->response(function (Request $request, array $headers) {
        return response('發文太頻繁了,請稍後再試。', 429, $headers);
    });
});

每秒級流量限制(Laravel 11 新增)

除了大家熟悉的「每分鐘限制幾次請求」,Laravel 11 起 Limit 也支援更細緻的每秒級限流:

RateLimiter::for('login', function (Request $request) {
    return Limit::perSecond(1)->by($request->ip());
});

這對防禦暴力破解、或需要嚴格限制高頻操作(例如某些金融交易 API)的情境特別實用——以前只能限制「每分鐘 5 次」,現在可以精準限制「每秒最多 1 次」,避免使用者在同一分鐘裡把額度瞬間打完。

路由快取

路由數量多、或大量使用 Closure 路由時,快取能加快路由解析速度:

php artisan route:cache   # 產生快取
php artisan route:clear   # 清除快取

要注意:Closure 形式的路由(直接寫函式,不是指向 Controller 方法)沒辦法被快取route:cache 遇到這種寫法會直接報錯。這也是為什麼實務上路由檔案通常會盡量都指向 Controller 方法,Closure 路由留給原型驗證或非常簡單的情境。

常見錯誤與踩雷點

  • bootstrap/app.php 裡呼叫 RateLimiter::for():如前面提到,這個時機點 Facade 未必可用,正確位置是 AppServiceProvider::boot()
  • route:cache 之後改路由沒生效:跟 Config 快取是同樣的道理,改完路由要記得 route:clear 或重新 route:cache,開發階段不建議常態開啟路由快取。
  • Resource 路由順序寫錯,導致 {post} 參數吃掉了 create:如果你手動列路由而不是用 Route::resource()GET /posts/{post} 一定要寫在 GET /posts/create 之後,不然 create 這個字會被誤判成 {post} 參數值。
  • 改了 getRouteKeyName() 卻忘記既有連結(例如已經發出去的 Email、書籤)全部失效:把路由模型綁定的欄位從 id 換成 slug,會讓所有舊網址(/posts/1)無法再對應到正確的文章,正式專案如果要做這個切換,通常需要搭配轉址規則過渡一段時間。
  • 簽章網址過期時間設太長,變相失去簽章機制的保護意義temporarySignedRoute 的第二個參數是有效期限,設得太長(例如一年)會讓連結長期有效,如果連結不慎外洩,攻擊視窗也跟著拉長,依實際使用情境設定合理的過期時間。
  • throttle 中介層的節流 key 沒有正確區分使用者,導致一個使用者被鎖住連帶影響所有人Limit::by() 沒有正確傳入識別碼(例如漏寫、或所有請求共用同一把 key)時,限流會變成全域生效而不是「每個使用者各自限制」,務必確認 by() 傳入的值真的能區分不同使用者/IP。

小結

路由的核心概念——動詞語意、Resource 路由、命名、群組——在 Laravel 13 完全沒變,今天真正的更新重點是 RateLimiter 的定義位置從已消失的 RouteServiceProvider 搬到 AppServiceProvider,以及 Laravel 11 新增的每秒級流量限制。另外也補上了路由模型綁定的自訂欄位、簽章網址這兩個實務上很常用到的進階主題。我們也正式定義了 blog-appposts Resource 路由,後面 Controller、RESTful API 章節都會延續這個設計。

條條大路通羅馬 — 西方諺語

明日預告

Day 8 從龐大的 Helpers 參考手冊裡,挑出真正常用的 20 幾個函式整理成一份速查表,包含 Laravel 11 新增的 once()


上一篇
我推的Laravel S2|Day 06:環境變數與 Config:.env 加密、Debug、維護模式
下一篇
我推的Laravel S2|Day 08:好用的 Helpers 精選:Arr、Str、Number、Path、URL、once()
系列文
我推的Laravel S2!9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言