Eloquent:修改器与类型转换
简介
访问器、修改器与属性类型转换,可在你获取或设置模型实例上的属性时转换 Eloquent 属性值。例如,你可能希望使用 Laravel 加密器 在数据库中存储加密值,并在通过 Eloquent 模型访问时自动解密;或者,希望把数据库中存储的 JSON 字符串在通过模型访问时转换为数组。
访问器与修改器
定义访问器
访问器会在访问 Eloquent 属性时转换其值。要定义访问器,请在模型上创建受保护方法以表示可访问属性。在适用时,方法名应对应真实底层模型属性 / 数据库列的「驼峰」表示。
本例中,我们为 first_name 属性定义访问器。Eloquent 在尝试获取 first_name 属性值时会自动调用该访问器。所有属性访问器 / 修改器方法都必须声明返回类型提示为 Illuminate\Database\Eloquent\Casts\Attribute:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
/**
* Get the user's first name.
*/
protected function firstName(): Attribute
{
return Attribute::make(
get: fn (string $value) => ucfirst($value),
);
}
}所有访问器方法都返回一个 Attribute 实例,用于定义属性如何被访问,以及(可选地)如何被修改。本例中我们只定义如何访问属性,因此向 Attribute 类构造函数提供 get 参数。
可以看出,列的原始值会传给访问器,供你处理并返回。要获取访问器的值,只需访问模型实例上的 first_name 属性:
use App\Models\User;
$user = User::find(1);
$firstName = $user->first_name;
INFO
若希望这些计算值加入模型的数组 / JSON 表示,你需要追加它们。
从多个属性构建值对象
有时访问器需要将多个模型属性转换为单个「值对象」。为此,get 闭包可接受第二个参数 $attributes,该参数会自动提供给闭包,并包含模型当前全部属性的数组:
use App\Support\Address;
use Illuminate\Database\Eloquent\Casts\Attribute;
/**
* Interact with the user's address.
*/
protected function address(): Attribute
{
return Attribute::make(
get: fn (mixed $value, array $attributes) => new Address(
$attributes['address_line_one'],
$attributes['address_line_two'],
),
);
}访问器缓存
当访问器返回值对象时,对值对象的任何更改都会在模型保存前自动同步回模型。这是因为 Eloquent 会保留访问器返回的实例,以便每次调用访问器时返回同一实例:
use App\Models\User;
$user = User::find(1);
$user->address->lineOne = 'Updated Address Line 1 Value';
$user->address->lineTwo = 'Updated Address Line 2 Value';
$user->save();
不过,有时你可能希望对字符串、布尔值等原始类型启用缓存,尤其是在计算开销较大时。为此,可在定义访问器时调用 shouldCache 方法:
protected function hash(): Attribute
{
return Attribute::make(
get: fn (string $value) => bcrypt(gzuncompress($value)),
)->shouldCache();
}若希望禁用属性的对象缓存行为,可在定义属性时调用 withoutObjectCaching 方法:
/**
* Interact with the user's address.
*/
protected function address(): Attribute
{
return Attribute::make(
get: fn (mixed $value, array $attributes) => new Address(
$attributes['address_line_one'],
$attributes['address_line_two'],
),
)->withoutObjectCaching();
}定义修改器
修改器会在设置 Eloquent 属性时转换其值。要定义修改器,可在定义属性时提供 set 参数。下面为 first_name 属性定义修改器。当我们尝试在模型上设置 first_name 的值时,该修改器会自动被调用:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
/**
* Interact with the user's first name.
*/
protected function firstName(): Attribute
{
return Attribute::make(
get: fn (string $value) => ucfirst($value),
set: fn (string $value) => strtolower($value),
);
}
}修改器闭包会收到正在设置到属性上的值,供你处理并返回处理后的值。要使用修改器,只需在 Eloquent 模型上设置 first_name 属性:
use App\Models\User;
$user = User::find(1);
$user->first_name = 'Sally';
本例中,set 回调会收到值 Sally。修改器随后对该名字应用 strtolower,并将结果写入模型内部的 $attributes 数组。
修改多个属性
有时修改器需要在底层模型上设置多个属性。为此,可从 set 闭包返回数组。数组中的每个键应对应与模型关联的底层属性 / 数据库列:
use App\Support\Address;
use Illuminate\Database\Eloquent\Casts\Attribute;
/**
* Interact with the user's address.
*/
protected function address(): Attribute
{
return Attribute::make(
get: fn (mixed $value, array $attributes) => new Address(
$attributes['address_line_one'],
$attributes['address_line_two'],
),
set: fn (Address $value) => [
'address_line_one' => $value->lineOne,
'address_line_two' => $value->lineTwo,
],
);
}属性类型转换
属性类型转换提供与访问器、修改器类似的功能,但无需在模型上定义额外方法。模型的 casts 方法可方便地将属性转换为常见数据类型。
casts 方法应返回一个数组,键为要转换的属性名,值为希望将该列转换成的类型。支持的转换类型有:
arrayAsStringable::classbooleancollectiondatedatetimeimmutable_dateimmutable_datetimedecimal:<precision>doubleencryptedencrypted:arrayencrypted:collectionencrypted:objectfloathashedintegerobjectrealstringtimestamp
为演示属性类型转换,我们将把数据库中以整数(0 或 1)存储的 is_admin 属性转换为布尔值:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'is_admin' => 'boolean',
];
}
}定义转换后,访问 is_admin 时始终会转为布尔值,即使底层值在数据库中以整数存储:
$user = App\Models\User::find(1);
if ($user->is_admin) {
// ...
}
若需要在运行时添加临时转换,可使用 mergeCasts 方法。这些转换定义会追加到模型上已有的转换中:
$user->mergeCasts([
'is_admin' => 'integer',
'options' => 'object',
]);
WARNING
值为 null 的属性不会被转换。此外,切勿定义与关联同名的转换(或属性),也不要对模型主键指定转换。
Stringable 转换
你可以使用 Illuminate\Database\Eloquent\Casts\AsStringable 转换类,将模型属性转换为流畅的 Illuminate\Support\Stringable 对象:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\AsStringable;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'directory' => AsStringable::class,
];
}
}数组与 JSON 转换
在处理以序列化 JSON 存储的列时,array 转换特别有用。例如,若数据库有包含序列化 JSON 的 JSON 或 TEXT 字段,为该属性添加 array 转换后,通过 Eloquent 模型访问时会自动反序列化为 PHP 数组:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'options' => 'array',
];
}
}定义转换后,访问 options 属性时会自动从 JSON 反序列化为 PHP 数组。设置 options 时,给定数组会自动序列化回 JSON 以便存储:
use App\Models\User;
$user = User::find(1);
$options = $user->options;
$options['key'] = 'value';
$user->options = $options;
$user->save();
若要以更简洁的语法更新 JSON 属性的单个字段,你可以使该属性可批量赋值,并在调用 update 方法时使用 -> 运算符:
$user = User::find(1);
$user->update(['options->key' => 'value']);
ArrayObject 与集合转换
虽然标准 array 转换对许多应用已足够,但仍有一些缺点。由于 array 转换返回原始类型,无法直接修改数组的某个偏移。例如,下列代码会触发 PHP 错误:
$user = User::find(1);
$user->options['key'] = $value;
为此,Laravel 提供了 AsArrayObject 转换,将 JSON 属性转换为 ArrayObject 类。该功能基于 Laravel 的自定义转换实现,可智能缓存并转换被修改的对象,从而允许修改单个偏移量而不会触发 PHP 错误。要使用 AsArrayObject 转换,只需将其赋给某个属性:
use Illuminate\Database\Eloquent\Casts\AsArrayObject;
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'options' => AsArrayObject::class,
];
}同样,Laravel 提供了 AsCollection 转换,将 JSON 属性转换为 Laravel 集合实例:
use Illuminate\Database\Eloquent\Casts\AsCollection;
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'options' => AsCollection::class,
];
}若希望 AsCollection 实例化自定义集合类而非 Laravel 基础集合类,可将集合类名作为转换参数提供:
use App\Collections\OptionCollection;
use Illuminate\Database\Eloquent\Casts\AsCollection;
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'options' => AsCollection::using(OptionCollection::class),
];
}日期转换
默认情况下,Eloquent 会将 created_at 与 updated_at 列转换为 Carbon 实例。Carbon 扩展了 PHP 的 DateTime 类并提供多种实用方法。你可在模型的 casts 方法中定义额外日期转换。通常应使用 datetime 或 immutable_datetime 转换类型。
定义 date 或 datetime 转换时,还可以指定日期格式。该格式会在模型序列化为数组或 JSON 时使用:
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'created_at' => 'datetime:Y-m-d',
];
}当列被转换为日期时,可将对应模型属性设为 UNIX 时间戳、日期字符串(Y-m-d)、日期时间字符串,或 DateTime / Carbon 实例。日期值会被正确转换并存入数据库。
你可以通过在模型上定义 serializeDate 方法,自定义模型所有日期的默认序列化格式。该方法不会影响日期在数据库中的存储格式:
/**
* Prepare a date for array / JSON serialization.
*/
protected function serializeDate(DateTimeInterface $date): string
{
return $date->format('Y-m-d');
}若要指定模型日期实际存入数据库时使用的格式,应在模型上定义 $dateFormat 属性:
/**
* The storage format of the model's date columns.
*
* @var string
*/
protected $dateFormat = 'U';日期转换、序列化与时区
默认情况下,date 与 datetime 转换会将日期序列化为 UTC ISO-8601 日期字符串(YYYY-MM-DDTHH:MM:SS.uuuuuuZ),与应用 timezone 配置无关。强烈建议始终使用该序列化格式,并通过保持应用 timezone 配置为默认 UTC 来以 UTC 存储日期。在整个应用中一致使用 UTC,可最大程度地与其他 PHP、JavaScript 日期处理库互通。
若对 date 或 datetime 应用了自定义格式(例如 datetime:Y-m-d H:i:s),序列化时会使用 Carbon 实例的内部时区,通常即应用 timezone 配置。但需注意:created_at、updated_at 等 timestamp 列不受此影响,始终按 UTC 格式化,与应用时区无关。
枚举转换
Eloquent 也允许将属性值转换为 PHP 枚举。为此,可在模型的 casts 方法中指定要转换的属性与枚举:
use App\Enums\ServerStatus;
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'status' => ServerStatus::class,
];
}在模型上定义转换后,与该属性交互时会自动在枚举与底层值之间转换:
if ($server->status == ServerStatus::Provisioned) {
$server->status = ServerStatus::Ready;
$server->save();
}
转换枚举数组
有时你可能需要在单列中存储枚举值数组。为此,可使用 Laravel 提供的 AsEnumArrayObject 或 AsEnumCollection 转换:
use App\Enums\ServerStatus;
use Illuminate\Database\Eloquent\Casts\AsEnumCollection;
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'statuses' => AsEnumCollection::of(ServerStatus::class),
];
}加密转换
encrypted 转换会使用 Laravel 内置的加密功能加密模型属性值。此外,encrypted:array、encrypted:collection、encrypted:object、AsEncryptedArrayObject 与 AsEncryptedCollection 的行为与其未加密对应物类似;但正如你所预期,存入数据库时底层值会被加密。
由于加密文本最终长度不可预测且长于明文,请确保相关数据库列为 TEXT 或更大类型。此外,由于值在数据库中已加密,你将无法查询或搜索加密属性值。
密钥轮换
如你所知,Laravel 使用应用 app 配置文件中的 key 配置值加密字符串。该值通常对应 APP_KEY 环境变量。若需要轮换应用的加密密钥,你需要使用新密钥手动重新加密已加密的属性。
查询时转换
有时你可能需要在执行查询时应用转换,例如从表中选择原始值。请看下面的查询:
use App\Models\Post;
use App\Models\User;
$users = User::select([
'users.*',
'last_posted_at' => Post::selectRaw('MAX(created_at)')
->whereColumn('user_id', 'users.id')
])->get();
该查询结果中的 last_posted_at 属性会是普通字符串。若能在执行查询时对其应用 datetime 转换会很理想。幸运的是,可以使用 withCasts 方法实现:
$users = User::select([
'users.*',
'last_posted_at' => Post::selectRaw('MAX(created_at)')
->whereColumn('user_id', 'users.id')
])->withCasts([
'last_posted_at' => 'datetime'
])->get();
自定义转换
Laravel 提供多种内置实用转换类型;但有时你可能需要自定义转换类型。要创建转换,请执行 make:cast Artisan 命令。新转换类会放在 app/Casts 目录中:
php artisan make:cast Json所有自定义转换类都实现 CastsAttributes 接口。实现该接口的类必须定义 get 与 set 方法。get 负责将数据库原始值转换为转换后的值,set 则应将转换后的值变回可存入数据库的原始值。下面我们以自定义转换重新实现内置的 json 转换类型:
<?php
namespace App\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
class Json implements CastsAttributes
{
/**
* Cast the given value.
*
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function get(Model $model, string $key, mixed $value, array $attributes): array
{
return json_decode($value, true);
}
/**
* Prepare the given value for storage.
*
* @param array<string, mixed> $attributes
*/
public function set(Model $model, string $key, mixed $value, array $attributes): string
{
return json_encode($value);
}
}定义自定义转换类型后,可使用其类名将其附加到模型属性:
<?php
namespace App\Models;
use App\Casts\Json;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'options' => Json::class,
];
}
}值对象转换
你不限于将值转换为原始类型,也可以转换为对象。将值转换为对象的自定义转换与转换为原始类型非常相似;不过,set 方法应返回键值对数组,用于在模型上设置可存储的原始值。
下面定义一个自定义转换类,将多个模型值转换为单个 Address 值对象。假设 Address 有两个公共属性:lineOne 与 lineTwo:
<?php
namespace App\Casts;
use App\ValueObjects\Address as AddressValueObject;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;
class Address implements CastsAttributes
{
/**
* Cast the given value.
*
* @param array<string, mixed> $attributes
*/
public function get(Model $model, string $key, mixed $value, array $attributes): AddressValueObject
{
return new AddressValueObject(
$attributes['address_line_one'],
$attributes['address_line_two']
);
}
/**
* Prepare the given value for storage.
*
* @param array<string, mixed> $attributes
* @return array<string, string>
*/
public function set(Model $model, string $key, mixed $value, array $attributes): array
{
if (! $value instanceof AddressValueObject) {
throw new InvalidArgumentException('The given value is not an Address instance.');
}
return [
'address_line_one' => $value->lineOne,
'address_line_two' => $value->lineTwo,
];
}
}转换为值对象时,对值对象的任何更改都会在模型保存前自动同步回模型:
use App\Models\User;
$user = User::find(1);
$user->address->lineOne = 'Updated Address Value';
$user->save();
INFO
若计划将包含值对象的 Eloquent 模型序列化为 JSON 或数组,应在值对象上实现 Illuminate\Contracts\Support\Arrayable 与 JsonSerializable 接口。
值对象缓存
转换为值对象的属性在解析时会被 Eloquent 缓存。因此再次访问该属性时会返回同一对象实例。
若希望禁用自定义转换类的对象缓存行为,可在自定义转换类上声明公共属性 withoutObjectCaching:
class Address implements CastsAttributes
{
public bool $withoutObjectCaching = true;
// ...
}数组 / JSON 序列化
使用 toArray 与 toJson 将 Eloquent 模型转换为数组或 JSON 时,只要自定义转换的值对象实现了 Illuminate\Contracts\Support\Arrayable 与 JsonSerializable 接口,通常也会被序列化。但使用第三方库提供的值对象时,你可能无法向该对象添加这些接口。
因此,你可以指定由自定义转换类负责序列化值对象。为此,自定义转换类应实现 Illuminate\Contracts\Database\Eloquent\SerializesCastableAttributes 接口。该接口要求类包含 serialize 方法,并返回值对象的序列化形式:
/**
* Get the serialized representation of the value.
*
* @param array<string, mixed> $attributes
*/
public function serialize(Model $model, string $key, mixed $value, array $attributes): string
{
return (string) $value;
}入站转换
有时你可能需要编写只在模型上设置值时进行转换、而在从模型获取属性时不做任何操作的自定义转换类。
仅入站的自定义转换应实现 CastsInboundAttributes 接口,该接口只要求定义 set 方法。可使用带 --inbound 选项的 make:cast Artisan 命令生成仅入站转换类:
php artisan make:cast Hash --inbound仅入站转换的经典例子是「哈希」转换。例如,我们可以定义一个按给定算法对入站值进行哈希的转换:
<?php
namespace App\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsInboundAttributes;
use Illuminate\Database\Eloquent\Model;
class Hash implements CastsInboundAttributes
{
/**
* Create a new cast class instance.
*/
public function __construct(
protected string|null $algorithm = null,
) {}
/**
* Prepare the given value for storage.
*
* @param array<string, mixed> $attributes
*/
public function set(Model $model, string $key, mixed $value, array $attributes): string
{
return is_null($this->algorithm)
? bcrypt($value)
: hash($this->algorithm, $value);
}
}转换参数
将自定义转换附加到模型时,可用 : 将参数与类名分隔,多个参数以逗号分隔。这些参数会传给转换类的构造函数:
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'secret' => Hash::class.':sha256',
];
}可转换对象(Castables)
你可能希望让应用的值对象自行定义自定义转换类。与其把自定义转换类附加到模型,不如附加实现了 Illuminate\Contracts\Database\Eloquent\Castable 接口的值对象类:
use App\ValueObjects\Address;
protected function casts(): array
{
return [
'address' => Address::class,
];
}实现 Castable 接口的对象必须定义 castUsing 方法,返回负责在 Castable 类与底层值之间转换的自定义转换器类名:
<?php
namespace App\ValueObjects;
use Illuminate\Contracts\Database\Eloquent\Castable;
use App\Casts\Address as AddressCast;
class Address implements Castable
{
/**
* Get the name of the caster class to use when casting from / to this cast target.
*
* @param array<string, mixed> $arguments
*/
public static function castUsing(array $arguments): string
{
return AddressCast::class;
}
}使用 Castable 类时,仍可在 casts 方法定义中提供参数。这些参数会传给 castUsing 方法:
use App\ValueObjects\Address;
protected function casts(): array
{
return [
'address' => Address::class.':argument',
];
}Castables 与匿名转换类
将「castables」与 PHP 的匿名类结合,可将值对象及其转换逻辑定义为单个可转换对象。为此,从值对象的 castUsing 方法返回匿名类。该匿名类应实现 CastsAttributes 接口:
<?php
namespace App\ValueObjects;
use Illuminate\Contracts\Database\Eloquent\Castable;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
class Address implements Castable
{
// ...
/**
* Get the caster class to use when casting from / to this cast target.
*
* @param array<string, mixed> $arguments
*/
public static function castUsing(array $arguments): CastsAttributes
{
return new class implements CastsAttributes
{
public function get(Model $model, string $key, mixed $value, array $attributes): Address
{
return new Address(
$attributes['address_line_one'],
$attributes['address_line_two']
);
}
public function set(Model $model, string $key, mixed $value, array $attributes): array
{
return [
'address_line_one' => $value->lineOne,
'address_line_two' => $value->lineTwo,
];
}
};
}
}