表单验证与错误处理
这一课你将能做到
- 在控制器中用
validate() 快速校验输入,并读懂自动返回的 422 与错误包
- 用 FormRequest 类把规则、授权、错误消息集中管理,并处理嵌套数组与文件校验
- 编写自定义验证规则,学会用 Rule 对象表达唯一值、存在性、枚举等复杂约束
为什么要学
「永远不要信任客户端输入」是后端第一课。Spring Boot 里你用 Bean Validation(@NotBlank、@Min、@Valid),由 Hibernate Validator 执行,失败后在 BindingResult 或全局异常里处理。Laravel 的验证层覆盖同样的场景,但规则以数组或字符串表达,开箱即用近百条规则,且能自动生成字段级错误消息——中文提示也只需发布语言文件。
验证还有两个工程价值:它与授权(FormRequest 的 authorize())、旧输入回填(old())无缝衔接;它是异常 API 统一响应的第一个真实案例。本课把「下单」接口的规则完整写透。
先建立图像
请求进入
│
▼
FormRequest::authorize() ──false──► 403
│true
▼
rules() 逐条校验
│全部通过 └──► 控制器方法内 $request->validated() 只拿干净数据
▼
ValidationException(422)
├─ web:back() + errors + old 输入(Blade @error 回显)
└─ api:{ message, errors: { field: [msgs...] } } JSON
规则对照:
| Laravel | Bean Validation |
|---|
required | @NotNull / @NotBlank |
string / integer / numeric | 类型绑定 + @Pattern |
min:3 / max:20(字符串看长度,数字看大小) | @Size / @Min/@Max |
email | @Email |
unique:users,email | 需手写查询校验 |
exists:orders,id | 需手写 |
confirmed(需有 xxx_confirmation 字段) | 手写两次比对 |
in:a,b / 枚举 Rule | @Pattern 或自定义 |
跟着做
步骤 1:控制器内快速验证
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class OrderController extends Controller
{
public function store(Request $request)
{
$validated = $request->validate([
'channel' => ['required', 'string', 'in:alipay,wechat,mock'],
'address_id' => ['required', 'integer', 'exists:addresses,id'],
'remark' => ['nullable', 'string', 'max:200'],
'items' => ['required', 'array', 'min:1'],
'items.*.product_id' => ['required', 'integer', 'exists:products,id'],
'items.*.quantity' => ['required', 'integer', 'min:1', 'max:99'],
'coupon_code' => ['nullable', 'string', 'size:8'],
]);
// 只有通过校验的字段会出现在这里
return response()->json(['received' => $validated], 201);
}
}
curl -i -X POST http://127.0.0.1:8000/api/orders \
-H "Content-Type: application/json" \
-d '{"channel":"xxx","items":[]}'
# HTTP/1.1 422 ...
# {"message":"The selected channel is invalid.","errors":{"channel":[...],"items":[...]}}
步骤 2:用 FormRequest 抽离规则
php artisan make:request StoreOrderRequest
app/Http/Requests/StoreOrderRequest.php:
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreOrderRequest extends FormRequest
{
// 授权:未登录直接 403,规则根本不执行
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'channel' => ['required', 'string', 'in:alipay,wechat,mock'],
'address_id' => ['required', 'integer',
Rule_exists_address($this->user()->id)],
'items' => ['required', 'array', 'min:1', 'max:50'],
'items.*.product_id' => ['required', 'integer', 'exists:products,id'],
'items.*.quantity' => ['required', 'integer', 'min:1', 'max:99'],
'remark' => ['nullable', 'string', 'max:200'],
];
}
// 自定义字段名(中文提示里更好读)
public function attributes(): array
{
return [
'channel' => '支付渠道',
'address_id' => '收货地址',
'items' => '商品明细',
'items.*.product_id' => '商品',
'items.*.quantity' => '数量',
];
}
// 自定义消息(不依赖全局语言包时)
public function messages(): array
{
return [
'items.required' => '至少选择一件商品',
'items.*.quantity.max' => '单件商品最多购买 99 件',
];
}
}
其中「地址必须属于当前用户」用闭包规则或 Rule 对象表达(防止用别人的地址下单):
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
// 简单写法:闭包规则
'address_id' => ['required', 'integer', function (string $attribute, mixed $value, Closure $fail) {
$belongs = \App\Models\Address::where('id', $value)
->where('user_id', $this->user()->id)
->exists();
if (! $belongs) {
$fail('收货地址无效');
}
}],
public function store(StoreOrderRequest $request)
{
$data = $request->validated(); // ['channel'=>..., 'items'=>[...], ...]
// 交给 OrderService
}
步骤 3:中文化错误提示
Laravel 11 默认只带英文语言文件。安装社区中文语言包:
composer require laravel-lang/common --dev
php artisan lang:add zh_CN
之后在 config/app.php 中设 'locale' => 'zh_CN',验证失败的 message 即为中文;也可按请求动态切换 app()->setLocale('zh_CN')。字段中文名由 FormRequest 的 attributes() 提供。
步骤 4:文件与条件规则
public function rules(): array
{
return [
'avatar' => ['required', 'file', 'image', 'mimes:jpg,png,webp', 'max:2048'], // KB
'channel' => ['required', 'in:alipay,wechat'],
// 支付宝需要支付宝流水号占位:复杂条件用 Rule::requiredIf
'trade_no' => [
Rule_required_if_alipay($this),
'string', 'size:32',
],
];
}
// 上面两个辅助位的真实写法:
use Illuminate\Validation\Rule;
'address_id' => ['required', 'integer',
Rule::exists('addresses', 'id')->where('user_id', $this->user()->id)],
'trade_no' => [
Rule::requiredIf(fn () => $this->channel === 'alipay'),
'nullable', 'string', 'size:32',
],
常用条件规则还有 exclude_if、sometimes(字段存在才校验)、prohibited(多租户场景禁止客户端传租户字段)。
步骤 5:唯一值与更新场景
注册与更新资料都要校验 email 唯一,但更新时必须排除自己:
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:50'],
'email' => [
'required', 'email',
Rule::unique('users', 'email')->ignore($this->user()->id),
// 软删除场景默认会忽略已删除行;需要限定作用域:
// ->where(fn ($q) => $q->where('tenant_id', $this->user()->tenant_id))
],
];
}
注册接口(无排除):Rule::unique('users', 'email')。
步骤 6:自定义规则类
php artisan make:rule UsableCoupon
app/Rules/UsableCoupon.php:
<?php
namespace App\Rules;
use App\Models\Coupon;
use Closure;
use Illuminate\Contracts\Validation\DataAwareRule;
use Illuminate\Contracts\Validation\ValidationRule;
class UsableCoupon implements ValidationRule, DataAwareRule
{
protected array $data = [];
public function setData(array $data): static
{
$this->data = $data;
return $this;
}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$coupon = Coupon::where('code', $value)->first();
if (! $coupon) {
$fail('优惠券不存在');
return;
}
if (! $coupon->usableBy($this->data['user_id'] ?? null)) {
$fail('该优惠券不适用于当前账户');
}
}
}
使用:'coupon_code' => ['nullable', 'string', new UsableCoupon()]。Laravel 10 后推荐这种实现 ValidationRule 的轻量写法,旧版 passes/message 接口仍然支持。
讲清楚要点
- 校验在控制器之前发生:FormRequest 的解析由容器在调用方法前完成,失败就抛异常,控制器里不必写 if。
validated() 只含通过规则的字段:配合 prohibited 可防止客户端偷偷传 is_admin、paid_at 等内部字段——这属于纵深防御。
- min/max 的语义随规则变化:对字符串是字符数,对数字是数值大小,对数组是元素个数,对文件是 KB。写金额校验时建议明确
numeric 或 integer 再配 min。
- 嵌套数组用点号:
items.*.quantity;错误也按路径出现在 errors['items.0.quantity'],前端可据此定位每一行。
- 数据库规则(exists/unique)会查库:批量循环校验要注意查询次数;Rule::exists 每次校验一条查询是正常设计,但对超大数组可考虑先
pluck 一次再用 in 规则。
- 异常响应可定制:在
bootstrap/app.php 的 withExceptions 中捕获 ValidationException 可调整 JSON 结构;网页端自动回填依赖 web 中间件的 Session。
- 正则规则能不用就不用:
regex: 可读性差且容易写错,优先用内置规则组合;PHP 8.2 的 filter_var / enums 也可结合。
你可能会懵的地方
- 字符串写法
'required|email' 和数组写法 ['required','email'] 有什么区别?
规则内要传含管道符的值(如正则里有 |)时只能用数组;团队统一用数组更安全,功能完全一致。
- 校验 nullable 的字段,空字符串算不算通过?
nullable 表示允许 null;提交空字符串仍会继续走后续规则。想把空字符串转成 null,用中间件 ConvertEmptyStringsToNull。
- 为什么 unique 更新时一直报「已被占用」?
忘了
ignore($id),把自己的记录也当成冲突。
- FormRequest 的 authorize 和 Policy 重复吗?
authorize 适合粗粒度门槛(登录、基础角色);资源级规则(这条地址是否属于本人)交给 Policy 或规则类。简单项目二者合一也常见。
- 密码字段用什么规则?
required(注册)/ confirmed(两次输入)/ min:8;不要限制过死的字符集(会降低密码多样性),也不要给密码加 max 过低。泄露检查可接 password 规则(比对常见泄露密码库)。
错法对照
| 错法 | 问题 | 正解 |
|---|
User::create($request->all()) 不做校验 | 脏数据 + 提权风险 | FormRequest 白名单后 validated() |
| 前端校验通过就认为安全 | 接口可被直接调用 | 规则全部在服务端 |
校验失败手动 return ['code'=>1] 且状态码 200 | 语义混乱、客户端难处理 | 保留 422 + errors 结构 |
| 金额允许小数且随意 round | 对账误差 | 分为单位存整数,规则用 integer |
| 更新 unique 不排除自身 | 用户无法保存自己的资料 | ignore($model->id) |
| 自定义规则里静默 return 不给错误 | 用户不知道为何失败 | 调用 $fail('原因') |
卡点急救
- 规则好像没执行:确认 FormRequest 已作为控制器参数类型声明(不是 Request);缓存过期用
optimize:clear;规则文件命名空间在 App\Http\Requests。
- 文件校验总失败:确认表单带
enctype="multipart/form-data";post_max_size / upload_max_filesize 要大于文件;mimes 按扩展名判断,想验真实内容用 mimetypes:。
- 中文消息不显示:已
lang:add zh_CN 且 locale 正确;自定义 attributes() 提供字段名;跑过 config 缓存则重新生成。
面试常问
-
Laravel 的验证在请求流程的哪一步执行?失败后发生什么?
路由器解析控制器方法时,容器先实例化 FormRequest:先 authorize 后 rules;失败抛 ValidationException。网页请求被异常处理器转为重定向回上一页,错误与旧输入闪存进 Session;JSON 请求返回 422 与 message/errors。控制器本体在全部通过后才执行。
-
如何防止批量赋值与字段伪造(如伪造 is_admin)?
三层:FormRequest 只声明业务字段并 validated();对危险字段加 prohibited 规则或直接不在规则中出现;模型 $fillable 白名单兜底。即使绕过前两层,模型也不会写入未授权字段。
-
exists / unique 规则在高并发或大表上要注意什么?
被校验列要有索引(唯一列本身有索引);大数组明细逐项 exists 会产生大量查询,可改为一次性查合法 ID 集合再比对;唯一冲突在最终 INSERT 时仍可能发生(并发窗口),代码要捕获唯一键异常做兜底。
自测三问
- 下单接口要求「地址属于当前用户、明细至少一条、每件数量 1-99」,你会在 FormRequest 里写哪些规则?
- 用户更新邮箱时 unique 规则怎么写才能不把自己判重?
- 网页表单和 JSON API 在校验失败时分别收到什么?错误信息在前端分别怎么取用?
小结一句话
验证是数据进入系统的安检门:FormRequest 定规则与授权,422 与字段级错误反馈客户端,自定义 Rule 沉淀业务约束,所有写入从此只信任干净数据。
延伸