跳至主要內容
技術

表單驗證與檔案上傳:讓使用者好好提交資料

表單驗證與檔案上傳:讓使用者好好提交資料
PHP/Laravel 完全指南 第 7 / 15 篇

本篇是「PHP/Laravel 完全指南」系列的第 7 / 15 篇。你可以從系列總覽開始閱讀,也可以直接接著看本文。

使用者輸入的資料,永遠不能信任。這不是偏執,是血淚教訓。前端驗證擋得了手滑的一般使用者,擋不了:

  • 開 DevTools 直接改掉 HTML 的人
  • curl 繞過瀏覽器直打你 API 的人
  • 自動化攻擊腳本

後端驗證是最後一道防線,也是唯一可靠的防線。

Laravel 的驗證系統大概是我用過最舒服的——內建超過 90 條驗證規則,從 requiredemailmaxexists: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 年前——如果後端照單全收,你的資料庫就被塞了一筆垃圾資料。

後端驗證攔的就是這種「形狀壞掉」的資料:空標題、負數價格、過去的截止日。前端的 requiredminLength 一個 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 自動做三件事:

  1. 把使用者重新導向回前一頁
  2. 所有錯誤訊息存進 Session($errors
  3. 舊的輸入值存進 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'],

跨框架對照

如果你從其他框架來,這裡做個對照:

概念LaravelExpress (Node.js)Django (Python)
驗證方式$request->validate()joi / zod / express-validatorDjango 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',
        ];
    }
}

三個關鍵方法:

  1. authorize()——權限檢查。回傳 false 就丟 403 Forbidden。搭配第六章〈認證與授權〉的 Policy 使用。
  2. rules()——驗證規則。跟 $request->validate() 的寫法一模一樣。
  3. 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>

三個關鍵點:

  1. old('title')——回填使用者上次輸入的值,驗證失敗後不用重新打字
  2. @error('title')——如果 title 欄位有錯誤,渲染裡面的內容
  3. {{ $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 或相容服務(正式環境推薦)

public disk 的檔案存在 storage/app/public/ 裡,但使用者透過瀏覽器只能存取 public/ 目錄。Laravel 用一個 symbolic link 把兩者連起來:

php artisan storage:link

這會建立 public/storagestorage/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>

幾個重要細節:

  1. enctype="multipart/form-data"——有檔案上傳的表單一定要加這個,否則後端收不到檔案。這是很多新手會忘的坑。
  2. @csrf——Laravel 的 CSRF 保護。沒有這個 token,POST 請求會被直接拒絕(419 status code)。
  3. old('field')——每個 input 都用 old() 回填值,驗證失敗時使用者不用重新填寫。
  4. @error('field')——每個欄位下面都有錯誤訊息區域,只在驗證失敗時顯示。
  5. 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. 什麼都不填直接送出 → 每個必填欄位下方都出現紅色錯誤訊息
  2. 標題只填 1 個字 → 出現「標題至少要 3 個字」
  3. 截止時間設成 2 小時後 → 出現「截止時間必須是至少 24 小時後」
  4. 上傳一個 5MB 的 PNG → 出現「圖片大小不能超過 2MB」
  5. 全部填正確 → 建立成功,重新導向到團購詳情頁

全程沒寫任何 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 模板(含 @errorold()
  • ✅ 商品圖片上傳、縮圖生成
  • ✅ Controller 保持精簡(每個 method 不超過十行核心邏輯)

下一章,我們要進入揪好買的心臟——跟團與成團邏輯。使用者怎麼「+1」跟團、什麼時候成團、截止時間到了怎麼處理。你會學到 Laravel 的 Session 機制、Cache facade、以及怎麼把模糊的業務需求轉化成清楚的程式碼。

留言討論

esc
輸入關鍵字搜尋文章...
查看收藏 →