昨天講完 sitemap:generate 這支指令怎麼把 sitemap.xml 寫進儲存空間,今天要講一個真實發生過的後續:sitemap.xml 產生出來了,路由也能正常回應,Google Search Console 卻回報讀不到這個檔案。
檔案存在、路由也通,問題出在哪?答案跟檔案內容一點關係都沒有——是回應本身的 Content-Type 不對,還牽連到一個完全不相干的 middleware。
response()->stream() 怎麼正確帶出 XML 的 Content-Typesitemap.xml 這個路由,一開始的寫法接近這樣:直接把儲存空間裡的檔案讀出來回傳。看起來合理——檔案內容確實是合法的 XML,路由也真的回應了 200。但 Google Search Console 的爬蟲讀到這個回應時,判定它「不是有效的 sitemap」,直接跳過。
追下去才發現:這個路由回應的 Content-Type header 帶的不是 application/xml,而是別的東西。瀏覽器打開這個網址看起來完全正常——瀏覽器對 Content-Type 沒那麼敏感,就算標成 text/plain 也會照樣把 XML 內容畫出來。但 Google 的爬蟲不是瀏覽器,它嚴格照 Content-Type 判斷這個回應該怎麼解讀,標錯了就直接不當一回事。
這是這個 bug 最陰險的地方:用瀏覽器手動驗證完全看不出問題,因為瀏覽器會幫你把錯的地方藏起來。
修這個 bug 花的時間比預期久,因為第一輪只把 Content-Type 補上去還是沒解決。後來才發現,sitemap.xml 這個路由當時掛在系統裡一個全站共用的 middleware 底下,這個 middleware 的職責跟 sitemap 完全無關——它是用來處理「依照使用者的設定,切換要載入哪一套畫面樣板」,過程中會寫入 session。
問題就出在這裡:session 的寫入動作,會在回應裡加上跟 session 有關的 cookie/header,這些額外的動作,加上 middleware 本身處理請求的方式,讓最終送出的回應不再是一個乾淨的 XML 串流。sitemap.xml 這種路由,理論上應該是全站最單純、最不該被任何跟「使用者狀態」有關的邏輯碰到的路由,卻意外被牽連進去。
修法分兩步:
response()->stream() 明確指定 Content-Type,而不是依賴框架的預設行為public function xml()
{
abort_unless(Storage::exists('sitemap.xml'), 404);
return response()->stream(function () {
fpassthru(Storage::readStream('sitemap.xml'));
}, 200, [
'Content-Type' => 'application/xml; charset=UTF-8',
]);
}
fpassthru() 搭配 Storage::readStream(),讓大檔案不用整個載進記憶體就能串流輸出,這也是為什麼選 response()->stream() 而不是直接 return Storage::get(...) 的原因——後者要先把整個檔案內容讀進字串,前者邊讀邊吐。
❌ 回應「能動」但格式沒把關
public function xml()
{
return Storage::response('sitemap.xml');
// 檔案內容沒問題,但 Content-Type 交給框架猜,
// 又掛在一個會寫 session 的全站 middleware 底下
}
✅ 明確控制格式,路由職責單純化
public function xml()
{
abort_unless(Storage::exists('sitemap.xml'), 404);
return response()->stream(function () {
fpassthru(Storage::readStream('sitemap.xml'));
}, 200, [
'Content-Type' => 'application/xml; charset=UTF-8',
]);
}
// 路由移出全站共用 middleware,不再意外觸發 session 寫入
「檔案是對的」「路由能回應」是兩件容易被誤判成「這功能沒問題」的假訊號。真正該驗證的是回應本身——用 curl -I 去看實際送出去的 header,而不是用瀏覽器眼睛看內容對不對。瀏覽器的容錯能力,恰恰是這類 bug 最容易被隱藏的地方。
另外一個更值得記住的教訓:一個全站共用的 middleware,會被哪些路由用到,往往比你以為的更廣。sitemap.xml 這種「看起來跟使用者狀態完全無關」的路由,也可能因為掛在錯誤的中介層底下,被拖進不該有的副作用裡。這個 middleware 後面還會在別的情境裡再次出現——它不只造成過這次 sitemap 的問題。
你的專案裡,有沒有一個全站共用的 middleware,掛在了所有路由底下?回想一下,有沒有哪個路由其實不需要那個 middleware 提供的功能,只是因為「全站共用」而被動掛上去,卻因此承接了不該有的副作用?
response()->stream() 明確指定 Content-Typecurl -I 看實際 header,不能只靠瀏覽器手動測試明天要換一個主題:一支 Queue Job 怎麼用 Generator 安全地迭代一段日期區間,去外部系統抓資料,還要處理「同一筆資料抓兩次」這種真實會發生的情況。