php学习Eloquent修改器源码示例解析

发布时间:2026/10/9 9:45:40
php学习Eloquent修改器源码示例解析
前言「修改器」是 Eloquent 里一个翻译上很容易混淆的词。中文社区常把 accessor访问器和 mutator修改器统称为「修改器」也有人把「写在读取侧的方法」也叫修改器。本文按官方手册的划分来讲读取时加工数据的叫访问器accessor写入时加工数据的叫修改器mutator两者在源码里走的是同一条分发路径getAttribute()/setAttribute()所以放在一起讲反而更清楚。第二个常见误解是以为访问器/修改器只是「语法糖」随便写写就行。实际上它们是 Eloquent 属性读写的第一道关卡很多看起来莫名其妙的行为——比如create()存进去的值和你写的不一样、批量update()之后修改器「失灵」、toArray()输出的字段和数据库列对不上——根因都在这里。本文分两部分先讲清楚分发机制也就是「为什么方法名必须叫成那样」再用可直接运行的例子演示新旧两套写法最后给出读源码时应该盯住的几个方法名。一、写入侧setAttribute()是怎么找到你的方法的Eloquent 里所有属性写入最终都汇聚到Illuminate\Database\Eloquent\Concerns\HasAttributes::setAttribute($key, $value)。这个过程大致是先判断这个 key 有没有「设置修改器」旧式方法或 Laravel 9 起的Attribute写法有则把原始值交给修改器用它的返回值作为最终存入$this-attributes的值没有则走类型转换cast、日期处理、JSON 编码等常规路径最后写入$this-attributes[$key]并标记该属性为「已修改」dirty。关键在第一步的命名约定旧式修改器的方法名是把列名转成 StudlyCase大驼峰后拼上前后缀。Str::studly(first_name)得到FirstName所以列first_name的修改器必须叫setFirstNameAttribute。列名和Str::studly()的结果对不上方法就永远不会被调用——而且不会报错症状是「值被原样存进去了」。这是新手最常卡住的地方。?php // 旧式写法Laravel 5 到 Laravel 11 都支持namespace App\Models;use Illuminate\Database\Eloquent\Model;class User extends Model{// 列名 first_name - setFirstNameAttributepublic function setFirstNameAttribute($value): void{$this-attributes[first_name] strtolower((string) $value);}// 列名 first_name - getFirstNameAttributepublic function getFirstNameAttribute($value): string{return ucfirst((string) $value);}}Laravel 9 起官方推荐的新写法换成Illuminate\Database\Eloquent\Casts\Attribute?php // 新式写法需要 Laravel 9 及以上PHP 8.0namespace App\Models;use Illuminate\Database\Eloquent\Casts\Attribute;use Illuminate\Database\Eloquent\Model;class User extends Model{// 方法名用 camelCasefirst_name - firstName// 返回类型必须声明为 Attribute框架靠反射读返回类型来做判定protected function firstName(): Attribute{return Attribute::make(get: fn (string|null $value) ucfirst((string) $value),set: fn (string|null $value) strtolower((string) $value),);}}新旧两套最实质的区别有三点命名规则不同。旧式用Str::studly()first_name→setFirstNameAttribute新式用Str::camel()first_name→firstName。写反了不会被调用。判定方式不同。旧式靠method_exists()匹配方法名新式还额外用反射检查方法的返回类型是否为Attribute。所以新式写法里返回类型注释漏了方法就等于不存在。可组合性不同。Attribute::make()的get/set都是闭包可以直接捕获外部变量、组合成链式的小函数旧式是两个独立方法共享逻辑得额外抽私有方法。另外Attribute::make()之外的写法还有Attribute::get(fn ($value) ...)和Attribute::set(fn ($value) ...)它们分别只定义单向转换等价于只写 get 或只写 set。二、读取侧getAttribute()的分发顺序读取路径的入口是HasAttributes::getAttribute($key)它先看这个 key 是不是一个「关联关系名」relationLoaded()/method_exists()判断不是才走属性路径。属性路径内部会先尝试模型方法再尝试 getter没有才把$this-attributes[$key]原样返回。一个可以直接跑的最小验证例子?php // 适用于 Laravel 9需要已配置好数据库连接与 users 表含 first_name 列use App\Models\User;$user new User();// 写入setAttribute - setFirstNameAttribute$user-first_name ALICE;// 此时 $attributes 里存的是 alice已被修改器处理var_dump($user-getAttributes()[first_name]); // string(5) alice// 读取getAttribute - getFirstNameAttributevar_dump($user-first_name); // string(5) Alice注意getAttributes()返回的是原始的、未经访问器的数组toArray()/attributesToArray()返回的才是加工过的访问器会参与。这个差异在调试时很有用怀疑访问器写错时先getAttributes()看真实存储值。JSON 序列化另有一条路径。模型被toArray()/toJson()时走的是attributesToArray()它会对每个 key 走一遍「数组化」的加工并且会把$appends里列的虚拟属性一并加上。所以一个只定义了访问器、数据库里没有对应列的属性想出现在 JSON 里就必须写进$appends?php // 适用于 Laravel 9class User extends Model{// 数据库没有 full_name 这一列靠访问器现场拼出来protected $appends [full_name];protected function fullName(): Attribute{return Attribute::get(fn (): string trim(($this-attributes[first_name] ?? ). .($this-attributes[last_name] ?? )));}}$appends也有对应的实例方法$model-append(full_name)和批量$model-append([a, b])用于只在某些接口里临时追加字段。三、读源码时该盯住哪几个方法名因为 Laravel 各版本对HasAttributes有过调整直接背「第几行做了什么」没有意义掌握入口方法名更实用。用编辑器在vendor/laravel/framework/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php里搜这几个名字即可方法名作用getAttribute()读取总入口先判关系再判属性setAttribute()写入总入口先判修改器再判类型转换getAttributeValue()按 key 取值串起转换与访问器hasGetMutator()/hasSetMutator()旧式访问器/修改器是否存在靠方法名匹配hasAttributeMutator()Laravel 9 新增判定新式Attribute写法靠反射返回类型attributesToArray()序列化路径访问器与$appends在这里生效另外有两个和类型转换相关的成员值得留意hasCast()与castAttribute()。当同一个属性既有$casts又有访问器时谁先谁后在不同大版本之间被调整过所以不要在访问器里假设$value一定是字符串未转换的数据库原始值或一定是数组已转换。稳妥的写法是自己判断类型?php // 不依赖框架内部的先后顺序use Illuminate\Database\Eloquent\Casts\Attribute;protected function options(): Attribute{return Attribute::get(function ($value) {if (is_string($value)) {$decoded json_decode($value, true);return is_array($decoded) ? $decoded : [];}return is_array($value) ? $value : [];});}这段写法在两种情况都成立属于「不确定就自己兜住」的务实做法。常见坑点❌ 把新式方法名写成firstNameAttribute()或getFirstName()方法永远不会被调用数据被原样存取。✅ 新式Laravel 9方法名就是Str::camel($key)即firstName()旧式是setFirstNameAttribute()/getFirstNameAttribute()。❌ 新式写法忘记写返回类型: Attribute只写了方法体。✅ 必须声明返回类型框架用反射判定Attribute才能识别漏写等于这个方法不存在。❌ 用Post::where(status, draft)-update([title $title])做批量更新期望修改器把标题转成小写。✅ 走 Eloquent 查询构造器的批量update()不会逐个实例化模型不触发修改器和类型转换。需要加工时逐个$post-update([...])或在写入前手动处理值。❌ 在访问器里执行数据库查询例如查头像 URL然后在列表页循环输出出现 N1 查询。✅ 列表场景提前with()预加载关联或把结果缓存到模型实例上访问器只做纯计算。❌ 修改器里用$this-attributes[x] ...之外的方式「返回」值例如改完不赋值存储值不变。✅ 旧式修改器的返回值会被框架用于赋值直接return strtolower($value);也是对的写法但要保证有返回值。❌ 只定义了访问器就以为toArray()里会出现这个字段。✅ 虚拟属性数据库无此列必须加入$appends才会出现在数组/JSON 序列化结果里。❌ 修改器里对null直接调用字符串函数create()传空值时报TypeError。✅ 参数类型写成string|null或先做(string) $value之类的兜底转换。❌ 给同一个属性同时写了旧式getXxxAttribute和新式xxx(): Attribute行为变得不可预期。✅ 同一个属性只用一种写法迁移到新语法时把旧方法删干净。总结维度旧式Laravel 5 起新式Laravel 9 起位置模型内普通方法模型内返回Attribute的方法命名setFirstNameAttribute/getFirstNameAttributefirstName()camelCase 列名判定依据method_exists()匹配方法名反射读取返回类型是否为Attribute类型标注可选必须写: Attribute序列化虚拟属性需加入$appends同上结论有三条。第一访问器和修改器的「魔法」其实就是命名约定加一层方法分发搞清Str::studly()与Str::camel()的区别绝大多数「写了不生效」的问题都能自己定位。第二查询构造器式的批量update()绕过模型实例修改器和类型转换都不生效这是生产环境最容易出数据事故的地方。第三$casts与访问器的先后顺序属于框架内部实现细节不同版本有调整代码里不要依赖它。