路由是使用者請求進到你的應用程式的第一道關卡。核心概念——HTTP 動詞、Resource 路由、路由參數、命名路由——放到 Laravel 13 完全沒有過時,唯一需要動刀的是 RateLimiter 的定義位置:舊寫法放在 RouteServiceProvider,而這個類別在 Day 5 講過的新骨架中已經不存在了。今天一邊複習路由核心概念,一邊把流量限制的寫法更新到現行版本,並補上 Laravel 11 新增的每秒級限流跟簽章網址這類進階主題。
routes/web.php 裡最基本的寫法:
Route::get('/', function () {
return view('welcome');
});
HTTP 動詞不是隨便選的,它們各自對應語意明確的操作:GET 取得資源、POST 建立新資源、PUT/PATCH 更新資源、DELETE 刪除資源。用對動詞除了語意清楚,也讓瀏覽器/代理伺服器的快取行為、安全性假設(例如瀏覽器不會主動對連結發出 DELETE 請求)符合預期。
這兩個常被搞混:PUT 代表完整更新(理論上要傳完整資料,沒傳到的欄位可能被視為要清空或恢復預設值,且多次執行結果應該一致,也就是「冪等」);PATCH 代表部分更新(只更新有帶到的欄位,例如只想改文章標題,不影響內文)。Laravel 的 Resource 路由兩者都會綁定,實務上多數團隊會統一只用 PATCH 處理部分更新,PUT 較少單獨使用。
瀏覽器的原生 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 表單本身的限制。
這是這個系列會反覆用到的寫法。從今天起,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']);
不需要 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]+');
}
上面範例裡拿到的都是字串 $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.index、posts.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 的輸出會被套件路由淹沒,很難一眼找到自己定義的那幾條。

舊寫法是在 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.php 的 withMiddleware() 閉包裡。那個時機點應用程式還沒完全啟動,門面(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 起 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,開發階段不建議常態開啟路由快取。{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-app 的 posts Resource 路由,後面 Controller、RESTful API 章節都會延續這個設計。
條條大路通羅馬 — 西方諺語
Day 8 從龐大的 Helpers 參考手冊裡,挑出真正常用的 20 幾個函式整理成一份速查表,包含 Laravel 11 新增的 once()。