Laravel 的 $casts 属性是一种默默提供便利的功能,它在不显山露水的情况下塑造了你处理数据的方式。在数组中放入像 boolean 或 datetime 这样的内置类型,Eloquent 就会在值到达你的控制器或视图之前,自动将数据库的原始字符串转换为更友好的格式。它让你的代码保持整洁。但一旦你尝试在该数组中直接调用自己的辅助函数,框架就会报错。像 'username' => 'encrypt_data()' 这样的写法是行不通的。Laravel 要求要么使用原生的转换关键字,要么使用实现了 CastsAttributes 接口的类。这一要求之所以存在,是因为转换层运行在 Eloquent 填充 (hydration) 和序列化循环的深层;它需要一个具有明确契约的可预测对象,而不是一个在运行时可能不存在的任意函数字符串。
为什么辅助函数在 $casts 数组中会失效
当 Eloquent 加载模型时,它会扫描 $casts 数组以决定如何转换每一列。框架可以识别特定的基本类型——string、integer、array、encrypted 等——以及实现了 CastsAttributes 的完全限定类名 (fully-qualified class names)。它不会将字符串作为代码进行求值。因此,'encrypt_data()' 会被视为一种未知的转换类型,从而触发错误。即使 Laravel 真的解析了它,也没有可靠的方法能以正确的顺序将模型实例、属性键、当前值以及周围的属性传递到你的函数中。该接口的存在正是为了标准化你的自定义逻辑与 Eloquent 内部机制之间的这种“握手”。
你有两种可靠的方法来弥补这一差距。一种侧重于长期复用,另一种则侧重于在单个模型中进行快速修复时的速度。
方法 1:编写自定义转换类 (Custom Cast Class)
如果相同的转换适用于多个字段或分布在多个模型中,那么专门的转换类是更整洁的选择。它拥有独立的文件,可以进行隔离的单元测试,并让你的模型免于重复的样板代码。
首先创建 app/Casts/CustomEncrypt.php。命名空间应与你的自动加载设置匹配,通常为 App\Casts。该类必须实现 Illuminate\Contracts\Database\Eloquent\CastsAttributes,这要求你定义两个方法:get 和 set。
<?php
namespace App\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
class CustomEncrypt implements CastsAttributes
{
public function get(Model $model, string $key, mixed $value, array $attributes): mixed
{
return decrypt_data($value);
}
public function set(Model $model, string $key, mixed $value, array $attributes): mixed
{
return encrypt_data($value);
}
}
方法签名非常重要。Eloquent 会向每个方法传递四个参数。$model 是正在被填充或保存的实例,如果你的逻辑依赖于其他字段,这允许你检查它们。$key 是当前正在转换的列名。$value 在执行 get 时是从数据库获取的原始字符串或 null,在执行 set 时是用户提供的数值。$attributes 是该行所有列的完整原始数组。你不需要每次都使用全部四个参数,但接口要求必须定义它们。
在上面的 get 方法中,decrypt_data($value) 在 Eloquent 从数据库获取行之后、值到达你的模型之前运行。在 set 方法中,encrypt_data($value) 在 INSERT 或 UPDATE 之前运行,确保数据库永远不会看到明文。
要将其连接起来,请在模型的 $casts 数组中引用该类:
protected $casts = [
'username' => CustomEncrypt::class,
'password' => CustomEncrypt::class,
];
因为你使用的是类常量,自动加载器会处理剩下的工作。如果你的应用程序稍后需要更换加密方案,你只需编辑一个文件,所有映射的字段就会立即改变行为。当你在许多表中管理敏感数据时,这种集中化的管理方式是无可比拟的。
方法 2:使用访问器 (Accessor) 和修改器 (Mutator)
有时,对于仅在一个地方起作用的转换,你并不想创建一个新文件。Laravel 的 Attribute 类允许你使用 PHP 8+ 的闭包 (closure) 语法直接在模型上定义 get 和 set 逻辑。
use Illuminate\Database\Eloquent\Casts\Attribute;
protected function username(): Attribute
{
return Attribute::make(
get: fn ($value) => decrypt_data($value),
set: fn ($value) => encrypt_data($value),
);
}
在这里,方法名必须与你目标列或属性匹配。当 Eloquent 从数据库读取 username 时,它会将原始值通过 get 闭包进行处理。当你为模型的 username 属性分配一个新值时,set 闭包会在 Eloquent 构建查询之前对其进行加密。
这种方法在原型开发或遗留模型中表现出色,因为在这些场景下,建立一个完整的转换类目录会显得过度设计。缺点是重复性。如果你稍后决定 email、phone 和 backup_code 也需要相同的处理,你最终会在多个方法或模型中复制这些闭包。这些冗余代码会不断累积。它还会导致你无法在不启动整个模型的情况下,在单元测试中复用该逻辑。
二者择其一
当你关注复用性、可测试性以及保持模型精简时,请使用自定义 Cast 类。这向其他开发者传达了一个信号:这种转换是应用程序中的一等公民概念,而非一次性的临时补丁。
当逻辑确实是局部化的、实验性的,或者不太可能在当前 Sprint 之后继续存在时,请使用 Accessor。这能让你快速推进,而不会在代码库中散落大量小文件。只需做好准备,一旦相同的模式出现第二次或第三次,就将其重构为 Cast 类。
需要注意的实践细节
自定义 Cast 功能非常强大,但如果你不留神,它们引入的行为可能会让你感到意外。首先,请记住 get 方法接收的是数据库返回的任何内容,包括 null。如果 decrypt_data 不支持 null 输入,请务必进行防御性处理:
public function get(Model $model, string $key, mixed $value, array $attributes): mixed
{
return is_null($value) ? null : decrypt_data($value);
}
其次,Cast 会在数组和 JSON 序列化期间运行。当你将模型作为 API 资源返回或调用 toArray() 时,Cast 的 get 逻辑仍然适用。这通常正是你想要的效果,但如果你正在暴露解密后的值,并且需要在其之上增加额外的可见性控制,这一点值得记住。
第三,也是最重要的一点,Cast 发生在数据检索之后。你无法针对转换后的值进行查询。像 User::where('username', 'john_doe')->first() 这样的查询会将字符串 john_doe 直接发送到数据库,它永远不会经过你的解密逻辑。如果该列在存储时是加密的,那么除非你针对加密后的密文本身进行搜索,否则该查询将一无所获。请据此规划你的数据库访问模式,因为 Cast 不能替代原生数据库函数或带索引的明文列。
自定义 Cast 类也是存放少量配置信息的理想场所。如果你需要构造函数参数——例如传递加密模式或格式字符串——Laravel 支持通过 $casts 数组来实现,例如使用 'field' => CustomEncrypt::class . ':arg' 这样的表达式,不过这比本文描述的基础设置要进阶一些。
核心总结
禁止直接在 $casts 中使用辅助函数的限制并非随意的官僚主义。它是在引导你编写显式、可测试且可复用的代码。自定义 Cast 类将零散的内联逻辑转变为可靠的组件,让你可以在多个模型之间共享而无需重复。而 Accessor 则为快速、局部的修复保留了空间,避免在只需要简单处理时还要专门创建一个文件。精通这两者,并根据问题的范围进行选择,这样即使在应用程序规模扩大后,你的 Eloquent 层也能保持良好的可读性。
