資料查出來了,接下來要讓使用者看得到。Blade 是 Laravel 的模板引擎,它最大的價值不是語法多花俏,而是把「輸出使用者資料時自動跳脫、避免 XSS」這件事變成預設行為——今天把常用指令、元件、表單處理整理成一份可以隨時回頭查的清單。
Day 4 我們選了 Livewire Starter Kit,畫面渲染這塊會大量用到 Blade。今天把核心語法、元件、表單處理整理一遍——Blade 從 Laravel 10 到 13 沒有破壞性變動,是這系列少數完全不用處理版本差異的章節。
Blade 模板用 {{ }} 輸出變數(自動跳脫 HTML,防止 XSS),{!! !!} 輸出未跳脫的原始 HTML(謹慎使用,只用在你完全信任的內容):
<h1>{{ $post->title }}</h1>
{!! $post->body_as_trusted_html !!}
{{-- --}} 是 Blade 的註解語法,跟 HTML 註解 <!-- --> 不同,Blade 註解不會出現在最終產生的 HTML 原始碼裡(HTML 註解會,只是瀏覽器不顯示),適合寫給開發者看、不希望使用者從「檢視原始碼」看到的說明文字。
@if ($post->published_at)
<span>已發布於 {{ $post->published_at->format('Y-m-d') }}</span>
@elseif ($post->created_at->gt(now()->subDay()))
<span>草稿(今天建立)</span>
@else
<span>草稿</span>
@endif
@unless ($post->is_featured)
<span>一般文章</span>
@endunless
@switch($post->status)
@case(PostStatus::Published)
已發布
@break
@case(PostStatus::PendingReview)
審核中
@break
@case(PostStatus::Draft)
草稿
@break
@default
未知狀態
@endswitch
這裡借用的是 Day 11 示範 Enum 轉型時提到的 PostStatus——要提醒的是 status 並不在我們 Day 9 建立的 posts 資料表欄位裡,Day 11 那段是用來示範「如果之後要把文章狀態從單純的 published_at 是否為 null 擴充成多種狀態,該怎麼做」。blog-app 目前實際判斷發布狀態,仍然是用上一節那種 @if ($post->published_at) 的寫法。這個範例純粹是為了示範 @switch 語法,如果你想在自己的專案跟著做,記得先補上對應的 Migration 跟 casts() 設定。
@foreach ($posts as $post)
<li>{{ $post->title }}</li>
@endforeach
@forelse ($posts as $post)
<li>{{ $post->title }}</li>
@empty
<li>目前還沒有文章</li>
@endforelse
@foreach 裡可以用 $loop 拿到目前迭代的中繼資訊,不用自己維護計數器:
@foreach ($posts as $post)
@if ($loop->first)
<p>共 {{ $loop->count }} 篇文章</p>
@endif
<li class="{{ $loop->even ? 'bg-gray-50' : '' }}">
第 {{ $loop->iteration }} 篇:{{ $post->title }}
@if ($loop->last)(最後一篇)@endif
</li>
@endforeach
$loop 除了 first/last/even/iteration/count,如果是巢狀迴圈,內層迴圈還能用 $loop->parent 存取外層迴圈的中繼資訊,這在渲染「文章列表裡每篇文章又要列出留言」這種巢狀結構時很實用。
<div @class([
'post-card',
'post-card--featured' => $post->is_featured,
'post-card--draft' => is_null($post->published_at),
])>
{{ $post->title }}
</div>
Blade 也提供跟認證/授權狀態直接對應的判斷指令,比自己寫 @if (auth()->check()) 更語意化:
@auth
<p>歡迎回來,{{ auth()->user()->name }}</p>
@endauth
@guest
<a href="{{ route('login') }}">請先登入</a>
@endguest
@can('update', $post)
<a href="{{ route('posts.edit', $post) }}">編輯文章</a>
@endcan
@cannot('update', $post)
<span>你沒有權限編輯這篇文章</span>
@endcannot
@can/@cannot 背後對應的是 Laravel 的授權系統(Gate/Policy)——判斷「這個使用者能不能對這篇文章做這件事」的規則不是寫在 Blade 裡,而是集中在一個 Policy 類別中。今天先知道這個語法存在、知道它會回答什麼問題就好,Day 13 講完 Controller 之後會實際建立 blog-app 的 PostPolicy,把「只有作者本人能編輯自己的文章」這條規則寫出來,屆時這裡的 @can('update', $post) 就會真的生效。
@include('posts.partials.card', ['post' => $post])
@once
@push('scripts')
<script src="/js/post-card.js"></script>
@endpush
@endonce
@once 確保裡面的內容(常見於 @push 一段只需要載入一次的資源)就算被同一個父視圖多次 @include,也只會輸出一次,避免同一段 <script> 被重複引入。
Blade 提供兩種讓多個頁面共用同一套外框(導覽列、頁尾)的方式,新舊專案常常混用,這裡把兩者的差異講清楚:
{{-- resources/views/layouts/app.blade.php --}}
<!DOCTYPE html>
<html>
<head><title>@yield('title', 'blog-app')</title></head>
<body>
@include('partials.nav')
<main>@yield('content')</main>
</body>
</html>
{{-- resources/views/posts/show.blade.php --}}
@extends('layouts.app')
@section('title', $post->title)
@section('content')
<h1>{{ $post->title }}</h1>
<p>{{ $post->body }}</p>
@endsection
{{-- resources/views/components/layout.blade.php --}}
<!DOCTYPE html>
<html>
<head><title>{{ $title ?? 'blog-app' }}</title></head>
<body>
@include('partials.nav')
<main>{{ $slot }}</main>
</body>
</html>
{{-- resources/views/posts/show.blade.php --}}
<x-layout :title="$post->title">
<h1>{{ $post->title }}</h1>
<p>{{ $post->body }}</p>
</x-layout>
兩種方式功能上等效,但新版 Starter Kit(Day 4)預設採用 Component 寫法,理由是它跟頁面內其他元件(<x-post-card> 這類)用同一套語法思考模型,不需要在「這是 layout 用 @extends」跟「這是元件用 <x-xxx>」之間切換心智模式。blog-app 這系列的範例統一採用 Component 寫法,如果你維護的是舊專案、看到 @extends/@yield 語法,不需要驚慌,它完全沒有被棄用,只是不再是新專案的預設風格。
比 @include 更結構化的做法,適合需要傳入具名屬性、有可複用邏輯的元件:
{{-- resources/views/components/post-card.blade.php --}}
@props(['post', 'featured' => false])
<article {{ $attributes->class(['post-card', 'post-card--featured' => $featured]) }}>
<h2>{{ $post->title }}</h2>
<p>{{ Str::limit($post->body, 100) }}</p>
{{ $slot }}
</article>
<x-post-card :post="$post" :featured="$post->is_featured">
<a href="{{ route('posts.show', $post) }}">閱讀更多</a>
</x-post-card>
$attributes 讓元件使用者可以額外傳入 HTML 屬性(像 class、data-*)並自動合併,$slot 則是元件標籤中間包住的內容,是打造可重用 UI 元件的基本功。
如果元件需要不只一個「內容插入點」(例如一張卡片同時需要「頁首」跟「頁尾」兩個可自訂區塊),用具名插槽:
{{-- resources/views/components/post-card.blade.php --}}
@props(['post'])
<article class="post-card">
@isset($header)
<header>{{ $header }}</header>
@endisset
<h2>{{ $post->title }}</h2>
<p>{{ Str::limit($post->body, 100) }}</p>
@isset($footer)
<footer>{{ $footer }}</footer>
@endisset
</article>
<x-post-card :post="$post">
<x-slot:header>
<span class="badge">精選</span>
</x-slot:header>
<x-slot:footer>
{{ $post->published_at->diffForHumans() }}
</x-slot:footer>
</x-post-card>
沒有具名的 x-slot 標籤包住的內容,就是預設的 $slot(範例前一節看到的用法);用 <x-slot:名稱> 明確標記的區塊,則對應元件裡的 $名稱 變數。
前面看到的都是「Anonymous Component」(只有一個 .blade.php 檔案,沒有對應的 PHP 類別),適合純呈現邏輯的元件。如果元件需要一些運算邏輯(不只是接收資料顯示),可以建立一個有對應 PHP 類別的元件:
php artisan make:component PostCard
// app/View/Components/PostCard.php
class PostCard extends Component
{
public function __construct(
public Post $post,
public bool $featured = false,
) {}
public function excerpt(): string
{
return Str::limit(strip_tags($this->post->body), 100);
}
public function render(): View
{
return view('components.post-card');
}
}
{{-- resources/views/components/post-card.blade.php --}}
<article {{ $attributes->class(['post-card', 'post-card--featured' => $featured]) }}>
<h2>{{ $post->title }}</h2>
<p>{{ $excerpt() }}</p>
</article>
使用方式跟匿名元件完全相同(<x-post-card :post="$post" />),差別只在於你現在有一個真正的 PHP 類別可以放運算邏輯,而不是把邏輯硬塞進 Blade 模板裡。判斷原則很單純:元件邏輯只是「把資料排版顯示」就用匿名元件;元件需要額外的運算、狀態、或依賴注入(例如需要用到 Service Container 解析出來的服務)就用 Class-Based 元件。
<form method="POST" action="{{ route('posts.update', $post) }}">
@csrf
@method('PATCH')
<input type="text" name="title" value="{{ old('title', $post->title) }}">
@error('title')
<span class="text-red-500">{{ $message }}</span>
@enderror
<button type="submit">更新</button>
</form>
@csrf:輸出一個隱藏的 CSRF token 欄位,Day 16 會講到為什麼這個機制在 Laravel 13 有一個重要的更新(中介層更名)。@method('PATCH'):HTML 表單原生只支援 GET/POST,這個指令會插入一個隱藏欄位讓 Laravel 把請求識別為 PATCH,才能正確對應到 Day 7 定義的 Resource 路由。@error('title') / $message:驗證失敗時顯示對應欄位的錯誤訊息,Day 17 會深入驗證規則。old('title', $post->title):表單驗證失敗導回時,優先顯示使用者剛剛輸入的值,沒有的話 fallback 回原本資料庫的值。@error 指令還支援指定錯誤訊息包(error bag),適合同一個頁面有多個表單、需要分開顯示各自驗證錯誤的情境:
@error('title', 'createPostForm')
<span>{{ $message }}</span>
@enderror
新版 Starter Kit 用 Vite 處理前端資源編譯(取代舊版的 Laravel Mix),Blade 用一個專門的指令引入編譯後的資源:
<head>
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
開發階段執行 npm run dev(Vite 開發伺服器),@vite() 會自動輸出指向開發伺服器的連結,支援模組熱重載(HMR);正式環境執行 npm run build 產生編譯好的靜態檔案後,@vite() 會改成輸出指向 public/build/ 目錄下帶雜湊值檔名的靜態資源連結。這整套機制不需要你手動判斷「現在是開發還是正式環境該輸出哪種連結」,@vite() 會自動偵測。
正式環境上線前,跟 Config、路由一樣,View 也建議預先編譯快取:
php artisan view:cache # 預先編譯所有 Blade 模板成 PHP,省去每次請求即時編譯的開銷
php artisan view:clear # 清除快取
Blade 模板本質上會被編譯成純 PHP 檔案(存在 storage/framework/views/),這個編譯過程預設是「第一次被請求時才編譯,之後直接用編譯好的版本」,view:cache 只是把這個編譯提前到部署時做完,避免正式環境的第一位使用者要承擔編譯延遲。
blog-app 選 Blade 的理由SPA(單頁應用,前端框架接管畫面渲染)跟 SSR/傳統伺服器渲染(後端直接吐出完整 HTML,像 Blade 這樣)是老問題,但這個取捨現在多了一個中間選項:Inertia(Day 4 提到的 React/Vue/Svelte Starter Kit 用的技術)。
三種取向簡單對照:
| 取向 | 代表技術 | 特性 |
|---|---|---|
| 傳統 SSR | Blade(今天的主題) | 每次請求後端組好完整 HTML,前端互動靠 Alpine.js/Livewire 等輕量方案補強,SEO 天生友善,心智負擔最低 |
| SPA + API | Laravel API + 獨立前端專案(React/Vue) | 前後端完全分離,適合前端團隊獨立運作、或要支援多種客戶端(Web/App 共用一套 API) |
| Inertia(Laravel 12+ Starter Kit 採用) | Inertia + React/Vue/Svelte | 用 SPA 的前端框架體驗,但沿用 Laravel 傳統的路由/Controller 寫法,不需要額外維護一套獨立的 API 層 |
blog-app 選 Livewire Starter Kit,本質上是留在「傳統 SSR + Blade」這個象限,只是用 Livewire 讓部分頁面元件有 SPA 般的互動體驗,不需要寫 JavaScript。這系列後續的範例程式碼都是 Blade/PHP,如果你的專案改用 React/Vue Starter Kit,後端 Controller/Model 邏輯完全通用,畫面呈現部分需要自行對照轉換成對應的前端元件。
@method('PATCH') 直接送出 PATCH 表單:HTML 表單沒有這個指令的話,實際送出的是 POST,Laravel 路由會找不到對應的 PATCH 路由而回傳 405,記得 Resource 路由的更新/刪除操作都要搭配 @method。{!! !!} 誤用在使用者輸入的內容上:這等於直接關閉 XSS 防護,任何使用者可控的字串(文章標題、留言內容)一律用 {{ }},只有你完全信任、不含使用者輸入的內容(例如後台管理員撰寫的公告 HTML)才考慮用 {!! !!}。$attributes 忘記合併,導致傳入的 class 被整個覆蓋:用 $attributes->class([...]) 而不是直接寫死 class="post-card",才能讓元件使用者額外附加的 class 正確合併進去而不是互相覆蓋。@vite() 在正式環境找不到編譯後的資源,噴出 Vite manifest not found 錯誤:部署流程忘記執行 npm run build,或建置產物沒有正確部署到伺服器,這是新手部署時常踩的坑,Day 29 部署上線那天會再提醒一次。view:cache 之後改了 Blade 模板卻沒生效:跟 Config/路由快取是同一個道理,開發階段不建議開啟這個快取,正式環境改完模板記得重新部署流程會處理快取更新(或手動 view:clear 再 view:cache)。Blade 的核心語法在 Laravel 10 到 13 之間沒有破壞性變動,今天把常用指令、Layout 的兩種組織方式、具名插槽、Class-Based 元件、表單處理跟前端資源整合複習一遍。SPA vs SSR 的討論則多了 Inertia 這個 Laravel 12+ 官方力推的中間選項,blog-app 選擇留在傳統 Blade 陣營,讓這系列的重心維持在後端概念。
玉不琢,不成器 — 《禮記・學記》
Day 13 把 View 接回 Controller:Resource Controller 怎麼寫、Magic Method、Request 物件常用方法一次講完。