Laravel的验证系统是框架里使用频率最高的组件之一,但真正把校验规则写严谨的项目并不多。内置的required、email、max:255这类规则只能覆盖通用场景,一旦涉及业务逻辑,比如订单金额不能超过用户余额、收货地址必须在配送范围内、上传文件必须是公司内部格式,就必须自定义规则。而自定义规则恰恰是最容易出安全问题的地方:类型判断不严、直接拼接SQL、信任客户端传来的隐藏字段,任何一个疏忽都可能让校验形同虚设。这篇文章从规则编写的几种方式讲起,逐步分析安全校验的完整方案。

一、自定义验证规则的三种实现方式与选型
Laravel提供了三种主流的自定义规则写法,理解它们的差异是写出安全校验的第一步。第一种是闭包规则,直接写在规则数组里,适合一次性的简单逻辑:
$request->validate([
'order_no' => [
'required',
'string',
function ($attribute, $value, $fail) {
// 订单号必须以当前年份开头且长度固定
if (!preg_match('/^\d{4}[A-Z]{2}\d{8}$/', $value)) {
$fail('订单号格式不正确');
}
},
],
]);闭包规则的优点是直观,逻辑就在眼前;缺点是无法复用,也难以单独测试。如果同一条规则要在多个控制器里使用,闭包会让代码迅速膨胀。
第二种是使用Illuminate\Contracts\Validation\Rule接口或者继承Illuminate\Contracts\Validation\ValidationRule(Laravel 10之后的风格)创建独立的规则类。规则类可以被注入依赖,能直接调用仓库层或数据库,这是它最核心的优势:
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class StockEnough implements ValidationRule
{
public function __construct(
protected int $warehouseId
) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$stock = \App\Models\Product::where('id', $value)
->where('warehouse_id', $this->warehouseId)
->value('stock');
if ($stock === null || $stock <= 0) {
$fail('该商品库存不足或不存在');
}
}
}第三种是使用Validator::extend注册全局规则,这种方式适合框架级通用规则,但在中型以上项目里会让验证逻辑分散到ServiceProvider中,可维护性较差。综合来看,规则类是安全性和可维护性最均衡的选择:它可以注入服务、可以写单元测试、可以在构造函数中传递上下文参数(比如当前用户、当前仓库),这是闭包规则做不到的。
二、规则编写中常见的安全陷阱
自定义规则出问题,往往不是语法错误,而是安全意识上的漏洞。第一个陷阱是类型混淆。PHP是弱类型语言,passes方法拿到的$value可能是字符串、数组甚至嵌套对象。很多开发者直接拿去做数值比较,比如$value > 100,如果攻击者传入一个数组,某些场景下比较结果会出乎意料。正确的做法是先用is_numeric、is_string做类型守卫,并且把类型规则(如integer、string)放在规则链的前面,让类型校验先于业务校验执行。
第二个陷阱是SQL注入风险。虽然Laravel的查询构造器自带参数绑定,但如果在规则里使用了whereRaw并且拼接了用户输入,风险就回来了。看一段有问题的代码:
// 错误示范:直接拼接用户输入
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$exists = \DB::select(
"SELECT COUNT(*) FROM users WHERE email = '" . $value . "'"
);
if ($exists[0]->count === 0) {
$fail('邮箱不存在');
}
}这段代码把验证值直接拼进SQL语句,攻击者构造一个带单引号的字符串就能注入。修复方式是始终坚持使用查询构造器或者参数绑定:
// 正确写法:使用查询构造器自动参数绑定
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$exists = \DB::table('users')
->where('email', $value)
->exists();
if (! $exists) {
$fail('邮箱不存在');
}
}第三个陷阱是信任客户端数据。比如价格校验规则依赖前端传来的原价字段做比对,攻击者篡改原价就能绕过校验。任何用于安全判断的数据,都应该从服务端会话或数据库中重新获取,而不是从同一请求的输入里读取。第四个陷阱是枚举值校验不完整,用in:admin,editor校验角色时,如果漏掉了枚举外的处理分支,或者大小写敏感配置不当,都可能产生越权风险。
三、构建从请求到落库的完整安全校验链
单条规则写得再好,如果校验入口本身有漏洞,依然会被绕过。安全的做法是把校验收敛到FormRequest层,让每个请求类型对应一个明确的验证类,避免在控制器里手写散乱的验证逻辑:
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class CreateOrderRequest extends FormRequest
{
public function authorize(): bool
{
// 授权判断放在这里,而不是规则里
return $this->user()->can('create', \App\Models\Order::class);
}
public function rules(): array
{
return [
'product_id' => ['required', 'integer', 'exists:products,id'],
'quantity' => ['required', 'integer', 'min:1', 'max:999'],
'remark' => ['nullable', 'string', 'max:500'],
];
}
public function messages(): array
{
return [
'product_id.exists' => '商品不存在或已下架',
];
}
}注意这里的关键细节:authorize方法负责权限层面的校验,rules负责数据格式层面的校验,两者职责分离。权限判断绝不放在规则里,因为规则的执行顺序依赖规则链排列,而authorize是最先执行且失败即中断的。
第二个关键细节是校验通过后只取白名单字段。很多人校验完直接$request->all()就丢给create方法,攻击者往请求里塞一个is_admin字段就可能导致批量赋值漏洞。正确做法是使用validated()方法,或者在模型上严格定义$fillable,两道防线都不可少:
// 控制器中只使用通过校验的字段
public function store(CreateOrderRequest $request)
{
$order = \App\Models\Order::create(
$request->validated()
);
return response()->json(['id' => $order->id]);
}
// 模型中白名单兜底
class Order extends Model
{
protected $fillable = ['product_id', 'quantity', 'remark'];
}第三个细节是二次校验落库前的业务约束。验证规则解决的是请求数据的合法性,但库存扣减这类涉及并发的事务校验,必须在数据库事务里再做一次,规则里的库存检查只能作为前置提示,不能作为最终依据。这种分层思路可以总结为:入口做格式校验、规则做业务校验、事务做并发校验、模型白名单做兜底。四层防线叠加,才能真正做到脏数据进不来、越权改不掉、并发不超卖。
最后补充一点工程实践:自定义规则类应该像业务代码一样编写单元测试,尤其是涉及数据库查询的规则,可以配合RefreshDatabase_trait_构造边界数据验证fail分支是否正确触发。验证规则是应用的第一道防线,值得投入和业务代码同等的严谨程度。
Laravel验证规则自定义验证数据校验修改时间:2026-09-06 04:26:39