表單驗證與檔案上傳:讓使用者好好提交資料
本篇是「PHP/Laravel 完全指南」系列的第 7 / 15 篇。你可以從系列總覽開始閱讀,也可以直接接著看本文。
使用者輸入的資料,永遠不能信任。這不是偏執,是血淚教訓。前端驗證擋得了手滑的一般使用者,擋不了:
- 開 DevTools 直接改掉 HTML 的人
- 用
curl繞過瀏覽器直打你 API 的人 - 自動化攻擊腳本
後端驗證是最後一道防線,也是唯一可靠的防線。
Laravel 的驗證系統大概是我用過最舒服的——內建超過 90 條驗證規則,從 required、email、max 到 exists:users,id 這種直接查資料庫的都有,而且錯誤訊息自動對應、自動回填表單,開發體驗好到讓你不想偷懶跳過驗證。
除了文字資料,表單常常還要處理檔案上傳——大頭照、商品圖片、附件。Laravel 的 Storage facade 把檔案操作抽象化,不管你底層用的是本地硬碟、Amazon S3 還是其他雲端儲存,程式碼寫法都一樣。搭配 Form Request 把驗證邏輯從 Controller 搬出去,你的 Controller 就能保持精簡,每個 method 不超過十行。
在「揪好買」團購平台裡,開團主建立團購時要填寫商品名稱、描述、最低成團人數、截止時間,還要上傳商品圖片。這一章我們會完整實作這張「開團」表單——從驗證規則、錯誤處理、圖片上傳到縮圖生成,每一步都用 Laravel 最佳實踐來做。
為什麼需要後端驗證:前端擋不住的事
你可能會想:「我前端已經用 JavaScript 做了驗證,使用者填錯會即時提示,應該夠了吧?」讓我示範一下為什麼不夠。
假設你的「開團」表單前端限制了標題必須填寫、價格必須是正數。但攻擊者完全可以繞過瀏覽器,直接用 curl 送請求:
# 繞過前端,直接打後端 API
curl -X POST http://localhost:8000/group-buys \
-H "Content-Type: application/json" \
-d '{"title": "", "price_per_unit": -100, "deadline": "1999-01-01"}'
前端驗證完全沒機會攔截這個請求。標題是空的、價格是負數、截止時間在 25 年前——如果後端照單全收,你的資料庫就被塞了一筆垃圾資料。
後端驗證攔的就是這種「形狀壞掉」的資料:空標題、負數價格、過去的截止日。前端的 required 和 minLength 一個 curl 就繞過了,所以這道把關只能放在後端。
所以原則很簡單:前端驗證是為了使用者體驗(即時反饋),後端驗證是為了把關資料的正確性。兩者缺一不可,但如果只能選一個,一定是後端。
不過我要先講清楚一件事,免得你誤會:validation 不是萬靈丹。required|string|max:2000 這種規則管的是「資料長什麼樣、符不符合業務規則」,它不會幫你擋 SQL injection,也不會擋 stored XSS。那是另外兩層的事:
- 防 SQL injection 靠的是 Eloquent / Query Builder 的參數化綁定。只要你不去手動拼 raw SQL 字串,注入這條路本來就被堵住了,跟你有沒有寫 validation 無關。
- 防 XSS 靠的是輸出端——Blade 的
{{ }}預設會做 HTML escape。所以 description 就算真的被塞了<script>,渲染時也只會變成純文字顯示,不會被瀏覽器執行(除非你用{!! !!}自己關掉轉義)。
換句話說,validation、ORM 參數化、Blade 轉義是三道不同的防線,各管各的,缺一不可。別把「我有寫 validation」當成「我的網站很安全」——這章先把資料正確性顧好,注入和 XSS 後面會另外談。
Validation Rules:Laravel 內建的驗證武器庫
Laravel 最簡單的驗證方式是直接在 Controller 裡呼叫 $request->validate():
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|min:3|max:100',
'description' => 'required|string|max:2000',
'product_name' => 'required|string|max:100',
'price_per_unit' => 'required|integer|min:1',
'min_participants' => 'required|integer|min:2|max:500',
'max_participants' => 'nullable|integer|gte:min_participants',
'deadline' => 'required|date|after:today',
'image' => 'nullable|image|mimes:jpg,png,webp|max:2048',
]);
// 如果走到這裡,表示驗證全部通過
// $validated 只包含驗證過的資料(安全)
GroupBuy::create($validated);
}
驗證失敗時,Laravel 自動做三件事:
- 把使用者重新導向回前一頁
- 把所有錯誤訊息存進 Session(
$errors) - 把舊的輸入值存進 Session(
old())——方便回填表單
你不需要手寫任何一行重導向或錯誤處理程式碼。
常用驗證規則速查表
Laravel 內建超過 90 條規則,這裡列出最常用的:
| 規則 | 說明 | 範例 |
|---|---|---|
required | 必填 | 'title' => 'required' |
string | 必須是字串 | 'name' => 'string' |
integer | 必須是整數 | 'price' => 'integer' |
numeric | 必須是數字(含小數) | 'weight' => 'numeric' |
min:N | 最小值/最小長度 | 'price' => 'min:1' |
max:N | 最大值/最大長度 | 'title' => 'max:100' |
email | 必須是合法 Email | 'email' => 'email' |
unique:table,column | 資料庫裡不能重複 | 'email' => 'unique:users,email' |
exists:table,column | 資料庫裡必須存在 | 'user_id' => 'exists:users,id' |
in:a,b,c | 只能是指定值之一 | 'status' => 'in:open,closed' |
date | 必須是合法日期 | 'deadline' => 'date' |
after:date | 必須在某日期之後 | 'deadline' => 'after:today' |
before:date | 必須在某日期之前 | 'end' => 'before:2027-01-01' |
gte:field | 大於等於另一個欄位 | 'max' => 'gte:min' |
image | 必須是圖片 | 'photo' => 'image' |
mimes:jpg,png | 限制檔案類型 | 'doc' => 'mimes:pdf,docx' |
max:N(檔案) | 最大檔案大小(KB) | 'image' => 'max:2048' |
nullable | 允許 null | 'bio' => 'nullable|string' |
confirmed | 必須有 _confirmation 欄位且值相同 | 'password' => 'confirmed' |
url | 必須是合法 URL | 'website' => 'url' |
boolean | 必須是布林值 | 'agree' => 'boolean' |
規則之間用 | 連接,也可以用陣列寫法:
// 管線寫法(簡短規則適用)
'title' => 'required|string|max:100',
// 陣列寫法(規則多或含特殊字元時更清楚)
'title' => ['required', 'string', 'max:100'],
跨框架對照
如果你從其他框架來,這裡做個對照:
| 概念 | Laravel | Express (Node.js) | Django (Python) |
|---|---|---|---|
| 驗證方式 | $request->validate() | joi / zod / express-validator | Django Forms / Serializers |
| 規則定義 | 字串 'required|email' | Schema 物件 z.string().email() | Field 類別 EmailField() |
| 錯誤回傳 | 自動 redirect + session | 手動回傳 JSON | 手動或 form.errors |
| 表單回填 | old('field') 自動可用 | 自己處理 | form.initial |
| 檔案驗證 | 同一套規則 'image|max:2048' | multer + 自己驗證 | FileField + validators |
Laravel 的優勢在於一站式:驗證規則、錯誤訊息、表單回填、檔案處理全部整合在一起。不像 Express 要自己組裝 multer + zod + 錯誤處理 middleware。
Form Request:把驗證邏輯從 Controller 搬出去
直接在 Controller 裡寫 $request->validate() 很方便,但當表單欄位一多、規則一複雜,Controller 就會變得臃腫。Laravel 的解法是 Form Request——一個專門負責驗證的類別。
php artisan make:request StoreGroupBuyRequest
這會產生 app/Http/Requests/StoreGroupBuyRequest.php:
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreGroupBuyRequest extends FormRequest
{
/**
* 這個使用者有權限送出這個表單嗎?
*/
public function authorize(): bool
{
// 搭配[第六章的 Policy](/blog/laravel-guide-auth-breeze-authorization)
return $this->user()->can('create', \App\Models\GroupBuy::class);
}
/**
* 驗證規則
*/
public function rules(): array
{
return [
'title' => ['required', 'string', 'min:3', 'max:100'],
'description' => ['required', 'string', 'max:2000'],
'product_name' => ['required', 'string', 'max:100'],
'price_per_unit' => ['required', 'integer', 'min:1'],
'min_participants' => ['required', 'integer', 'min:2', 'max:500'],
'max_participants' => ['nullable', 'integer', 'gte:min_participants'],
'deadline' => ['required', 'date', 'after:today'],
'image' => ['nullable', 'image', 'mimes:jpg,png,webp', 'max:2048'],
];
}
/**
* 自訂錯誤訊息(可選)
*/
public function messages(): array
{
return [
'title.required' => '團購標題是必填的',
'title.min' => '標題至少要 :min 個字',
'price_per_unit.min' => '單價必須大於 0',
'min_participants.min' => '最低成團人數至少要 :min 人',
'max_participants.gte' => '人數上限不能小於最低成團人數',
'deadline.after' => '截止時間必須是未來的日期',
'image.max' => '圖片大小不能超過 2MB',
];
}
}
三個關鍵方法:
authorize()——權限檢查。回傳false就丟 403 Forbidden。搭配第六章〈認證與授權〉的 Policy 使用。rules()——驗證規則。跟$request->validate()的寫法一模一樣。messages()——自訂錯誤訊息。可選,不寫就用 Laravel 預設的英文訊息。
在 Controller 裡使用 Form Request
重點來了。把 Request 改成 StoreGroupBuyRequest,Laravel 自動在進入 Controller 之前完成驗證:
use App\Http\Requests\StoreGroupBuyRequest;
class GroupBuyController extends Controller
{
public function store(StoreGroupBuyRequest $request)
{
// 走到這裡,表示驗證和授權都通過了
$validated = $request->validated();
$groupBuy = GroupBuy::create([
...$validated,
'user_id' => $request->user()->id,
]);
return redirect("/group-buys/{$groupBuy->id}")
->with('success', '團購建立成功!');
}
}
注意 store() 方法只有六行——沒有 if/else、沒有錯誤處理、沒有 redirect 回表單。所有髒活都由 Form Request 在 Controller 方法被呼叫之前處理完了。
這就是 Form Request 的威力:Controller 只負責業務邏輯,驗證邏輯完全分離。
什麼時候用 Form Request、什麼時候用 inline validate? 如果驗證規則不超過三條,inline
$request->validate()就好,別過度工程化。超過三條、或是同一組規則在多處使用(store 和 update 共用)、或是需要自訂authorize(),就抽成 Form Request。
自訂驗證規則:當內建不夠用
Laravel 內建 90 多條規則,但業務邏輯總有特殊需求。例如揪好買有一條規則:截止時間必須是至少 24 小時後(避免開團主設一個一小時後就截止的團購,其他人根本來不及跟)。
方法一:Closure Rule(內聯)
最快的做法,直接在 rules 陣列裡寫:
'deadline' => [
'required',
'date',
'after:today',
function (string $attribute, mixed $value, \Closure $fail) {
if (now()->diffInHours($value, absolute: false) < 24) {
$fail('截止時間必須是至少 24 小時後');
}
},
],
Closure 接收三個參數:欄位名稱、值、$fail callback。驗證失敗就呼叫 $fail() 並傳入錯誤訊息。
適合一次性的規則。如果同一個規則在多處使用,就該抽成 Rule class。
方法二:Rule Class(可重用)
php artisan make:rule MinHoursFromNow
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class MinHoursFromNow implements ValidationRule
{
public function __construct(
private int $hours = 24,
) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (now()->diffInHours($value, absolute: false) < $this->hours) {
$fail(":{$attribute} 必須是至少 {$this->hours} 小時後");
}
}
}
使用:
use App\Rules\MinHoursFromNow;
'deadline' => ['required', 'date', new MinHoursFromNow(24)],
乾淨、可重用、可測試。如果之後其他表單也需要「至少 N 小時後」的規則,直接 new MinHoursFromNow(48) 就好。
Error Message 中文化
Laravel 預設的錯誤訊息是英文:“The title field is required.”。對揪好買的台灣使用者來說,我們需要中文訊息。
設定語言檔
建立 lang/zh_TW/validation.php:
# 先建立目錄
mkdir -p lang/zh_TW
<?php
// lang/zh_TW/validation.php
return [
'required' => ':attribute 為必填',
'string' => ':attribute 必須是字串',
'integer' => ':attribute 必須是整數',
'min' => [
'numeric' => ':attribute 不能小於 :min',
'string' => ':attribute 至少要 :min 個字',
'file' => ':attribute 不能小於 :min KB',
],
'max' => [
'numeric' => ':attribute 不能大於 :max',
'string' => ':attribute 不能超過 :max 個字',
'file' => ':attribute 不能超過 :max KB',
],
'email' => ':attribute 格式不正確',
'unique' => ':attribute 已經被使用',
'date' => ':attribute 必須是有效日期',
'after' => ':attribute 必須是 :date 之後的日期',
'image' => ':attribute 必須是圖片',
'mimes' => ':attribute 只接受 :values 格式',
'confirmed' => ':attribute 與確認欄位不一致',
'gte' => [
'numeric' => ':attribute 必須大於或等於 :value',
],
// 自訂欄位名稱(把英文欄位名換成中文)
'attributes' => [
'title' => '團購標題',
'description' => '團購說明',
'product_name' => '商品名稱',
'price_per_unit' => '單價',
'min_participants' => '最低成團人數',
'max_participants' => '人數上限',
'deadline' => '截止時間',
'image' => '商品圖片',
'email' => '電子信箱',
'password' => '密碼',
'name' => '姓名',
],
];
然後在 config/app.php 設定語系:
'locale' => 'zh_TW',
現在驗證失敗時,使用者看到的是「團購標題 為必填」「截止時間 必須是 today 之後的日期」——比英文友善多了。
在 Blade 顯示錯誤訊息
<div class="mb-4">
<label for="title" class="block text-sm font-medium text-gray-700">團購標題</label>
<input
type="text"
name="title"
id="title"
value="{{ old('title') }}"
class="mt-1 w-full rounded-lg border px-4 py-2 @error('title') border-red-500 @enderror"
/>
@error('title')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
三個關鍵點:
old('title')——回填使用者上次輸入的值,驗證失敗後不用重新打字@error('title')——如果title欄位有錯誤,渲染裡面的內容{{ $message }}——該欄位的第一條錯誤訊息
@error 是 Blade 的語法糖,等價於:
@if($errors->has('title'))
<p>{{ $errors->first('title') }}</p>
@endif
檔案上傳:Storage Facade 統一管理
揪好買的開團表單需要上傳商品圖片。Laravel 的檔案處理圍繞一個核心概念:Storage Facade。不管你的檔案存在本地硬碟、Amazon S3、Google Cloud Storage 還是 DigitalOcean Spaces,程式碼寫法都一樣——只需要在設定檔切換 driver。
儲存空間設定
打開 config/filesystems.php:
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => env('APP_URL') . '/storage',
'visibility' => 'public',
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
],
],
三種常用 disk:
local——存在storage/app/private/,外部無法存取(適合敏感文件)public——存在storage/app/public/,可透過 URL 存取(適合商品圖片)s3——Amazon S3 或相容服務(正式環境推薦)
建立 Symbolic Link
public disk 的檔案存在 storage/app/public/ 裡,但使用者透過瀏覽器只能存取 public/ 目錄。Laravel 用一個 symbolic link 把兩者連起來:
php artisan storage:link
這會建立 public/storage → storage/app/public 的捷徑。之後 storage/app/public/images/product.jpg 就能透過 http://localhost:8000/storage/images/product.jpg 存取。
上傳檔案的基本操作
use Illuminate\Support\Facades\Storage;
// 儲存上傳的檔案(自動產生唯一檔名)
$path = $request->file('image')->store('group-buys', 'public');
// 回傳類似 "group-buys/abc123def456.jpg"
// 指定檔名
$path = $request->file('image')->storeAs(
'group-buys',
"gb-{$groupBuy->id}.jpg",
'public'
);
// 取得完整 URL
$url = Storage::disk('public')->url($path);
// "http://localhost:8000/storage/group-buys/abc123def456.jpg"
// 刪除檔案
Storage::disk('public')->delete($path);
// 檢查檔案是否存在
if (Storage::disk('public')->exists($path)) {
// ...
}
檔案驗證規則
上傳的檔案也要驗證——不只是檢查格式,更要限制大小,防止使用者傳一個 100MB 的檔案把你的硬碟塞爆:
'image' => [
'nullable', // 允許不上傳
'image', // 必須是圖片(jpg, png, gif, bmp, svg, webp)
'mimes:jpg,png,webp', // 限制為這三種格式
'max:2048', // 最大 2MB(2048 KB)
'dimensions:min_width=400,min_height=300', // 最小尺寸
],
為什麼限制 mimes?
image規則會接受 GIF、BMP、SVG 等格式。但商品圖片我們只要 JPG、PNG、WebP——這三種壓縮效率好、瀏覽器支援完整。SVG 甚至可能藏 XSS 攻擊。
圖片處理與縮圖
使用者上傳的原始圖片可能是 4000x3000 像素、5MB 大小。直接當商品圖顯示太慢了。我們需要產生適合網頁顯示的縮圖。
安裝 Intervention Image
composer require intervention/image
Intervention Image 是 PHP 生態系最主流的圖片處理套件,提供簡潔的 API 來裁剪、縮放、加浮水印等。
基本使用
use Intervention\Image\Laravel\Facades\Image;
use Intervention\Image\Encoders\WebpEncoder;
// 從上傳檔案建立 Image 實例
$image = Image::read($request->file('image'));
// 等比例縮放:寬度最大 800px,高度自動計算
$image->scale(width: 800);
// 裁剪成正方形(從中心裁)
$image->cover(400, 400);
// 轉成 WebP 格式並儲存
$encoded = $image->encode(new WebpEncoder(quality: 80));
Storage::disk('public')->put(
"group-buys/{$groupBuy->id}.webp",
$encoded
);
產生多種尺寸
商品圖片通常需要多種尺寸——列表頁的縮圖、詳情頁的大圖:
private function processImage(UploadedFile $file, int $groupBuyId): string
{
$image = Image::read($file);
$basePath = "group-buys/{$groupBuyId}";
// 原圖(限制最大寬度 1200px)
$original = (clone $image)->scale(width: 1200);
Storage::disk('public')->put(
"{$basePath}/original.webp",
$original->encode(new WebpEncoder(quality: 85))
);
// 縮圖(400x300,裁切)
$thumbnail = (clone $image)->cover(400, 300);
Storage::disk('public')->put(
"{$basePath}/thumbnail.webp",
$thumbnail->encode(new WebpEncoder(quality: 75))
);
return "{$basePath}/original.webp";
}
為什麼用 WebP? 相同畫質下,WebP 的檔案大小比 JPG 小 25-35%。2026 年所有主流瀏覽器都支援 WebP。除非你有特殊理由,新專案一律用 WebP。
實作:揪好買「開團」表單
把所有東西串起來。我們要實作完整的「開團」表單,包含驗證、圖片上傳、錯誤處理。
Step 1:Form Request
前面已經寫好了 StoreGroupBuyRequest,這裡再加上圖片處理的準備方法:
<?php
namespace App\Http\Requests;
use App\Rules\MinHoursFromNow;
use Illuminate\Foundation\Http\FormRequest;
class StoreGroupBuyRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', \App\Models\GroupBuy::class);
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'min:3', 'max:100'],
'description' => ['required', 'string', 'max:2000'],
'product_name' => ['required', 'string', 'max:100'],
'price_per_unit' => ['required', 'integer', 'min:1'],
'min_participants' => ['required', 'integer', 'min:2', 'max:500'],
'max_participants' => ['nullable', 'integer', 'gte:min_participants'],
'deadline' => ['required', 'date', new MinHoursFromNow(24)],
'image' => ['nullable', 'image', 'mimes:jpg,png,webp', 'max:2048'],
];
}
public function messages(): array
{
return [
'title.required' => '團購標題是必填的',
'title.min' => '標題至少要 :min 個字',
'price_per_unit.min' => '單價必須大於 0',
'min_participants.min' => '最低成團人數至少要 :min 人',
'max_participants.gte' => '人數上限不能小於最低成團人數',
'deadline.required' => '請設定截止時間',
'image.max' => '圖片大小不能超過 2MB',
'image.mimes' => '圖片只接受 JPG、PNG、WebP 格式',
];
}
}
Step 2:Controller
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreGroupBuyRequest;
use App\Models\GroupBuy;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Encoders\WebpEncoder;
use Intervention\Image\Laravel\Facades\Image;
class GroupBuyController extends Controller
{
public function create()
{
$this->authorize('create', GroupBuy::class);
return view('group-buys.create');
}
public function store(StoreGroupBuyRequest $request)
{
$validated = $request->validated();
// 建立團購(先不含圖片)
$groupBuy = GroupBuy::create([
...$validated,
'user_id' => $request->user()->id,
'status' => 'open',
]);
// 處理圖片上傳
if ($request->hasFile('image')) {
$groupBuy->update([
'image_path' => $this->processImage($request->file('image'), $groupBuy->id),
]);
}
return redirect("/group-buys/{$groupBuy->id}")
->with('success', '團購建立成功!開始分享給朋友吧。');
}
private function processImage($file, int $groupBuyId): string
{
$image = Image::read($file);
$basePath = "group-buys/{$groupBuyId}";
// 主圖
$main = (clone $image)->scale(width: 1200);
Storage::disk('public')->put(
"{$basePath}/main.webp",
$main->encode(new WebpEncoder(quality: 85))
);
// 縮圖
$thumb = (clone $image)->cover(400, 300);
Storage::disk('public')->put(
"{$basePath}/thumb.webp",
$thumb->encode(new WebpEncoder(quality: 75))
);
return "{$basePath}/main.webp";
}
}
Controller 裡的 store() 只做三件事:取得驗證過的資料、建立記錄、處理圖片。驗證邏輯全在 StoreGroupBuyRequest,圖片處理抽成 private method。每個 public method 都很短、意圖明確。
一個我必須誠實提醒的代價:這裡的
processImage()是同步跑的。Image::read()解一張大圖、再scale+cover產兩種尺寸、各自編成 WebP——這是 CPU 密集活,一張手機拍的幾 MB 大圖跑下來,從幾百毫秒到一兩秒都有可能。而這整段是卡在 request 週期裡的:使用者按下「開團」之後,就在那邊乾等到圖片全部處理完才看到回應,同時你的 PHP worker 也被這一個請求佔住,PHP-FPM 的執行時間上限一到還可能直接逾時。流量小、原型階段這樣寫完全沒問題,簡單直接。但只要同時開團的人一多、或圖片普遍很大,正確的做法是先把原檔存好、立刻回應使用者,再把processImage丟到 Queue/Job 背景產縮圖。判斷點很簡單:當你開始看到上傳變慢或偶發逾時,就是該改成非同步的時候。
Step 3:Blade 表單
resources/views/group-buys/create.blade.php:
<x-layouts.app title="開新團購">
<div class="max-w-2xl mx-auto">
<h1 class="text-2xl font-bold mb-6">開新團購</h1>
{{-- 全域成功訊息 --}} @if(session('success'))
<div class="bg-green-100 text-green-700 px-4 py-3 rounded-lg mb-6">
{{ session('success') }}
</div>
@endif
<form method="POST" action="/group-buys" enctype="multipart/form-data" class="space-y-6">
@csrf {{-- 團購標題 --}}
<div>
<label for="title" class="block text-sm font-medium text-gray-700">
團購標題 <span class="text-red-500">*</span>
</label>
<input
type="text"
name="title"
id="title"
value="{{ old('title') }}"
placeholder="例:阿里山高山茶 春茶團購"
class="mt-1 w-full rounded-lg border px-4 py-2 @error('title') border-red-500 @enderror"
/>
@error('title')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
{{-- 商品名稱 --}}
<div>
<label for="product_name" class="block text-sm font-medium text-gray-700">
商品名稱 <span class="text-red-500">*</span>
</label>
<input
type="text"
name="product_name"
id="product_name"
value="{{ old('product_name') }}"
placeholder="例:阿里山高山烏龍茶 150g"
class="mt-1 w-full rounded-lg border px-4 py-2 @error('product_name') border-red-500 @enderror"
/>
@error('product_name')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
{{-- 團購說明 --}}
<div>
<label for="description" class="block text-sm font-medium text-gray-700">
團購說明 <span class="text-red-500">*</span>
</label>
{{-- 注意:old('description') 頂格寫、</textarea 拆行,是為了避免 textarea 渲染出多餘的前後空白,這是刻意的排版而非格式錯誤 --}}
<textarea
name="description"
id="description"
rows="4"
placeholder="詳細描述商品內容、規格、取貨方式..."
class="mt-1 w-full rounded-lg border px-4 py-2 @error('description') border-red-500 @enderror"
>
{{ old('description') }}</textarea
>
@error('description')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
{{-- 單價與人數(兩欄並排) --}}
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label for="price_per_unit" class="block text-sm font-medium text-gray-700">
每份單價 (NT$) <span class="text-red-500">*</span>
</label>
<input
type="number"
name="price_per_unit"
id="price_per_unit"
value="{{ old('price_per_unit') }}"
min="1"
placeholder="250"
class="mt-1 w-full rounded-lg border px-4 py-2 @error('price_per_unit') border-red-500 @enderror"
/>
@error('price_per_unit')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div>
<label for="min_participants" class="block text-sm font-medium text-gray-700">
最低成團人數 <span class="text-red-500">*</span>
</label>
<input
type="number"
name="min_participants"
id="min_participants"
value="{{ old('min_participants') }}"
min="2"
placeholder="5"
class="mt-1 w-full rounded-lg border px-4 py-2 @error('min_participants') border-red-500 @enderror"
/>
@error('min_participants')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div>
<label for="max_participants" class="block text-sm font-medium text-gray-700">
人數上限
</label>
<input
type="number"
name="max_participants"
id="max_participants"
value="{{ old('max_participants') }}"
placeholder="不限"
class="mt-1 w-full rounded-lg border px-4 py-2 @error('max_participants') border-red-500 @enderror"
/>
@error('max_participants')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
</div>
{{-- 截止時間 --}}
<div>
<label for="deadline" class="block text-sm font-medium text-gray-700">
截止時間 <span class="text-red-500">*</span>
</label>
<input
type="datetime-local"
name="deadline"
id="deadline"
value="{{ old('deadline') }}"
class="mt-1 w-full rounded-lg border px-4 py-2 @error('deadline') border-red-500 @enderror"
/>
<p class="mt-1 text-xs text-gray-400">截止時間必須是至少 24 小時後</p>
@error('deadline')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
{{-- 商品圖片 --}}
<div>
<label for="image" class="block text-sm font-medium text-gray-700">商品圖片</label>
<input
type="file"
name="image"
id="image"
accept="image/jpeg,image/png,image/webp"
class="mt-1 w-full text-sm text-gray-500
file:mr-4 file:py-2 file:px-4
file:rounded-lg file:border-0
file:text-sm file:font-medium
file:bg-indigo-50 file:text-indigo-700
hover:file:bg-indigo-100"
/>
<p class="mt-1 text-xs text-gray-400">JPG、PNG、WebP 格式,最大 2MB</p>
@error('image')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
{{-- 送出按鈕 --}}
<div class="flex justify-end gap-4">
<a href="/group-buys" class="px-6 py-2 rounded-lg border text-gray-600 hover:bg-gray-50">
取消
</a>
<button
type="submit"
class="px-6 py-2 rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 transition"
>
建立團購
</button>
</div>
</form>
</div>
</x-layouts.app>
幾個重要細節:
enctype="multipart/form-data"——有檔案上傳的表單一定要加這個,否則後端收不到檔案。這是很多新手會忘的坑。@csrf——Laravel 的 CSRF 保護。沒有這個 token,POST 請求會被直接拒絕(419 status code)。old('field')——每個 input 都用old()回填值,驗證失敗時使用者不用重新填寫。@error('field')——每個欄位下面都有錯誤訊息區域,只在驗證失敗時顯示。accept="image/jpeg,image/png,image/webp"——前端的檔案類型限制,讓使用者在選檔案時只看到支援的格式。記住,這只是 UX 優化,後端的mimes規則才是真正的驗證。
Step 4:路由
確認 routes/web.php 有這兩條路由(第六章應該已經加了):
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/group-buys/create', [GroupBuyController::class, 'create']);
Route::post('/group-buys', [GroupBuyController::class, 'store']);
});
Step 5:驗證流程完整走一遍
php artisan serve
npm run dev
打開 http://localhost:8000/group-buys/create(需先登入):
- 什麼都不填直接送出 → 每個必填欄位下方都出現紅色錯誤訊息
- 標題只填 1 個字 → 出現「標題至少要 3 個字」
- 截止時間設成 2 小時後 → 出現「截止時間必須是至少 24 小時後」
- 上傳一個 5MB 的 PNG → 出現「圖片大小不能超過 2MB」
- 全部填正確 → 建立成功,重新導向到團購詳情頁
全程沒寫任何 JavaScript,表單驗證、錯誤顯示、值回填全部由 Laravel + Blade 搞定。
更新團購時的驗證
建立(Store)和更新(Update)的驗證規則通常很像但不完全一樣。例如更新時圖片不是必要的(保留舊圖),unique 規則要排除自己。你可以另建一個 UpdateGroupBuyRequest:
class UpdateGroupBuyRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('update', $this->route('groupBuy'));
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'min:3', 'max:100'],
'description' => ['required', 'string', 'max:2000'],
'product_name' => ['required', 'string', 'max:100'],
'price_per_unit' => ['required', 'integer', 'min:1'],
'min_participants' => ['required', 'integer', 'min:2', 'max:500'],
'max_participants' => ['nullable', 'integer', 'gte:min_participants'],
'deadline' => ['required', 'date', 'after:today'],
// 更新時圖片完全可選
'image' => ['nullable', 'image', 'mimes:jpg,png,webp', 'max:2048'],
];
}
}
在 Controller 裡更新時,要記得刪除舊圖片:
public function update(UpdateGroupBuyRequest $request, GroupBuy $groupBuy)
{
$groupBuy->update($request->validated());
if ($request->hasFile('image')) {
// 刪除舊圖片
if ($groupBuy->image_path) {
Storage::disk('public')->deleteDirectory(
dirname($groupBuy->image_path)
);
}
$groupBuy->update([
'image_path' => $this->processImage($request->file('image'), $groupBuy->id),
]);
}
return redirect("/group-buys/{$groupBuy->id}")
->with('success', '團購已更新');
}
小結:驗證是對使用者的尊重
好的驗證不只是擋壞資料——它是在告訴使用者「我知道你哪裡填錯了,這是正確的方向」。清楚的錯誤訊息、自動回填的表單值、友善的中文提示,這些細節讓使用者感受到產品的用心。
這一章我們走過了 Laravel 驗證與檔案上傳的完整流程:
- 後端驗證是唯一防線——永遠不要信任前端,
curl一行就能繞過所有 JavaScript $request->validate()——最快的驗證方式,適合簡單場景- Form Request——把驗證邏輯抽出 Controller,保持程式碼精簡。
rules()、authorize()、messages()三個方法各司其職 - 自訂規則——Closure rule 快速搞定一次性需求,Rule class 處理可重用的業務規則
- 中文化——
lang/zh_TW/validation.php搭配attributes設定,讓錯誤訊息說人話 - Storage Facade——統一的檔案操作 API,
store()、storeAs()、delete(),切換 S3 只改設定檔 - Intervention Image——圖片縮放、裁剪、轉 WebP,產生適合網頁的多種尺寸
揪好買進度:
- ✅
StoreGroupBuyRequest完整驗證規則 - ✅
MinHoursFromNow自訂驗證規則 - ✅ 中文錯誤訊息與欄位名稱
- ✅ 「開團」表單完整 Blade 模板(含
@error、old()) - ✅ 商品圖片上傳、縮圖生成
- ✅ Controller 保持精簡(每個 method 不超過十行核心邏輯)
下一章,我們要進入揪好買的心臟——跟團與成團邏輯。使用者怎麼「+1」跟團、什麼時候成團、截止時間到了怎麼處理。你會學到 Laravel 的 Session 機制、Cache facade、以及怎麼把模糊的業務需求轉化成清楚的程式碼。