Eloquent: 变更器与类型转换
介绍
访问器、变更器和属性类型转换允许您在检索或设置模型实例上的 Eloquent 属性值时进行转换。例如,您可能希望使用 Laravel 加密器 在数据库中存储值时对其进行加密,然后在访问 Eloquent 模型时自动解密该属性。或者,您可能希望将存储在数据库中的 JSON 字符串在通过 Eloquent 模型访问时转换为数组。
访问器和变更器
定义访问器
访问器在访问时转换 Eloquent 属性值。要定义访问器,请在模型上创建一个受保护的方法来表示可访问的属性。此方法名称应对应于真实底层模型属性/数据库列的“驼峰命名”表示法(如适用)。
在此示例中,我们将为 first_name
属性定义一个访问器。访问器将在尝试检索 first_name
属性的值时自动被 Eloquent 调用。所有属性访问器/变更器方法必须声明返回类型提示为 Illuminate\Database\Eloquent\Casts\Attribute
:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
/**
* 获取用户的名字。
*/
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;
如果您希望这些计算值被添加到模型的数组/JSON 表示中,您 需要将它们附加。
从多个属性构建值对象
有时,您的访问器可能需要将多个模型属性转换为单个“值对象”。为此,您的 get
闭包可以接受第二个参数 $attributes
,该参数将自动传递给闭包,并将包含模型当前属性的数组:
use App\Support\Address;
use Illuminate\Database\Eloquent\Casts\Attribute;
/**
* 与用户的地址交互。
*/
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 = '更新的地址行 1 值';
$user->address->lineTwo = '更新的地址行 2 值';
$user->save();
但是,您有时可能希望为字符串和布尔值等原始值启用缓存,特别是如果它们计算密集。为此,您可以在定义访问器时调用 shouldCache
方法:
protected function hash(): Attribute
{
return Attribute::make(
get: fn (string $value) => bcrypt(gzuncompress($value)),
)->shouldCache();
}
如果您希望禁用属性的对象缓存行为,可以在定义属性时调用 withoutObjectCaching
方法:
/**
* 与用户的地址交互。
*/
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
{
/**
* 与用户的名字交互。
*/
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;
/**
* 与用户的地址交互。
*/
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
方法应返回一个数组,其中键是要转换的属性名称,值是您希望将列转换为的类型。支持的类型转换如下:
array
AsStringable::class
boolean
collection
date
datetime
immutable_date
immutable_datetime
decimal:<precision>
double
encrypted
encrypted:array
encrypted:collection
encrypted:object
float
hashed
integer
object
real
string
timestamp
为了演示属性类型转换,让我们将 is_admin
属性转换为布尔值,该属性在数据库中存储为整数(0
或 1
):
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
/**
* 获取应该转换的属性。
*
* @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',
]);
null
的属性将不会被转换。此外,您永远不应定义与关系同名的类型转换(或属性),或将类型转换分配给模型的主键。
可字符串化的类型转换
您可以使用 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
{
/**
* 获取应该转换的属性。
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'directory' => AsStringable::class,
];
}
}
数组和 JSON 类型转换
array
类型转换在处理存储为序列化 JSON 的列时特别有用。例如,如果您的数据库具有 JSON
或 TEXT
字段类型,包含序列化 JSON,则将 array
类型转换添加到该属性将自动在您通过 Eloquent 模型访问时将属性反序列化为 PHP 数组:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
/**
* 获取应该转换的属性。
*
* @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']);
数组对象和集合类型转换
尽管标准的 array
类型转换对于许多应用程序来说已经足够,但它确实有一些缺点。由于 array
类型转换返回原始类型,因此无法直接修改数组的偏移量。例如,以下代码将触发 PHP 错误:
$user = User::find(1);
$user->options['key'] = $value;
为了解决这个问题,Laravel 提供了一个 AsArrayObject
类型转换,将您的 JSON 属性转换为 ArrayObject 类。此功能是通过 Laravel 的 自定义类型转换 实现的,允许 Laravel 智能地缓存和转换变更的对象,以便可以在不触发 PHP 错误的情况下修改单个偏移量。要使用 AsArrayObject
类型转换,只需将其分配给一个属性:
use Illuminate\Database\Eloquent\Casts\AsArrayObject;
/**
* 获取应该转换的属性。
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'options' => AsArrayObject::class,
];
}
同样,Laravel 提供了一个 AsCollection
类型转换,将您的 JSON 属性转换为 Laravel 集合 实例:
use Illuminate\Database\Eloquent\Casts\AsCollection;
/**
* 获取应该转换的属性。
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'options' => AsCollection::class,
];
}
如果您希望 AsCollection
类型转换实例化自定义集合类而不是 Laravel 的基本集合类,您可以将集合类名称作为类型转换参数提供:
use App\Collections\OptionCollection;
use Illuminate\Database\Eloquent\Casts\AsCollection;
/**
* 获取应该转换的属性。
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'options' => AsCollection::using(OptionCollection::class),
];
}
日期类型转换
默认情况下,Eloquent 将 created_at
和 updated_at
列转换为 Carbon 的实例,该实例扩展了 PHP DateTime
类并提供了一系列有用的方法。您可以通过在模型的 casts
方法中定义额外的日期类型转换来转换其他日期属性。通常,日期应使用 datetime
或 immutable_datetime
类型转换。
在定义 date
或 datetime
类型转换时,您还可以指定日期的格式。此格式将在模型序列化为数组或 JSON 时使用 模型序列化:
/**
* 获取应该转换的属性。
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'created_at' => 'datetime:Y-m-d',
];
}
当列被转换为日期时,您可以将相应模型属性值设置为 UNIX 时间戳、日期字符串(Y-m-d
)、日期时间字符串或 DateTime
/ Carbon
实例。日期的值将被正确转换并存储在数据库中。
您可以通过在模型上定义 serializeDate
方法来自定义模型所有日期的默认序列化格式。此方法不会影响日期在数据库中的存储格式:
/**
* 准备日期以进行数组/JSON 序列化。
*/
protected function serializeDate(DateTimeInterface $date): string
{
return $date->format('Y-m-d');
}
要指定在实际存储模型的日期时应使用的格式,您应在模型上定义 $dateFormat
属性:
/**
* 模型日期列的存储格式。
*
* @var string
*/
protected $dateFormat = 'U';
日期类型转换、序列化和时区
默认情况下,date
和 datetime
类型转换将日期序列化为 UTC ISO-8601 日期字符串(YYYY-MM-DDTHH:MM:SS.uuuuuuZ
),无论您在应用程序的 timezone
配置选项中指定的时区如何。强烈建议您始终使用此序列化格式,并将应用程序的日期存储在 UTC 时区中,而不更改应用程序的 timezone
配置选项的默认 UTC
值。在整个应用程序中一致使用 UTC 时区将提供与用 PHP 和 JavaScript 编写的其他日期操作库的最大互操作性。
如果对 date
或 datetime
类型转换应用了自定义格式,例如 datetime:Y-m-d H:i:s
,则 Carbon 实例的内部时区将在日期序列化期间使用。通常,这将是您在应用程序的 timezone
配置选项中指定的时区。然而,重要的是要注意,诸如 created_at
和 updated_at
之类的时间戳列不受此行为的影响,并始终以 UTC 格式进行格式化,无论应用程序的时区设置如何。
枚举类型转换
Eloquent 还允许您将属性值转换为 PHP 枚举。要实现此目的,您可以在模型的 casts
方法中指定要转换的属性和枚举:
use App\Enums\ServerStatus;
/**
* 获取应该转换的属性。
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'status' => ServerStatus::class,
];
}
一旦在模型上定义了类型转换,指定的属性将在您与该属性交互时自动转换为枚举:
if ($server->status == ServerStatus::Provisioned) {
$server->status = ServerStatus::Ready;
$server->save();
}
转换枚举数组
有时,您可能需要让模型在单个列中存储枚举值的数组。为此,您可以利用 Laravel 提供的 AsEnumCollection
或 AsEnumArrayObject
类型转换:
use App\Enums\ServerStatus;
use Illuminate\Database\Eloquent\Casts\AsEnumCollection;
/**
* 获取应该转换的属性。
*
* @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
{
/**
* 转换给定值。
*
* @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);
}
/**
* 准备给定值以进行存储。
*
* @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
{
/**
* 获取应该转换的属性。
*
* @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
{
/**
* 转换给定值。
*
* @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']
);
}
/**
* 准备给定值以进行存储。
*
* @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('给定值不是 Address 实例。');
}
return [
'address_line_one' => $value->lineOne,
'address_line_two' => $value->lineTwo,
];
}
}
在将值转换为值对象时,对值对象所做的任何更改将在模型保存之前自动同步回模型:
use App\Models\User;
$user = User::find(1);
$user->address->lineOne = '更新的地址值';
$user->save();
如果您计划将包含值对象的 Eloquent 模型序列化为 JSON 或数组,则应在值对象上实现 Illuminate\Contracts\Support\Arrayable
和 JsonSerializable
接口。
值对象缓存
当解析为值对象的属性被解析时,它们会被 Eloquent 缓存。因此,如果再次访问该属性,将返回相同的对象实例。
如果您希望禁用自定义类型转换类的对象缓存行为,可以在自定义类型转换类上声明一个公共的 withoutObjectCaching
属性:
class Address implements CastsAttributes
{
public bool $withoutObjectCaching = true;
// ...
}
数组 / JSON 序列化
当 Eloquent 模型使用 toArray
和 toJson
方法转换为数组或 JSON 时,您的自定义类型转换值对象通常也会被序列化,只要它们实现 Illuminate\Contracts\Support\Arrayable
和 JsonSerializable
接口。然而,当使用第三方库提供的值对象时,您可能无法将这些接口添加到对象中。
因此,您可以指定您的自定义类型转换类将负责序列化值对象。为此,您的自定义类型转换类应实现 Illuminate\Contracts\Database\Eloquent\SerializesCastableAttributes
接口。此接口声明您的类应包含一个 serialize
方法,该方法应返回值对象的序列化形式:
/**
* 获取值的序列化表示。
*
* @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
{
/**
* 创建新的类型转换类实例。
*/
public function __construct(
protected string|null $algorithm = null,
) {}
/**
* 准备给定值以进行存储。
*
* @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);
}
}
类型转换参数
在将自定义类型转换附加到模型时,可以通过使用 :
字符分隔类名并用逗号分隔多个参数来指定类型转换参数。这些参数将传递给类型转换类的构造函数:
/**
* 获取应该转换的属性。
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'secret' => Hash::class.':sha256',
];
}
可转换对象
您可能希望允许应用程序的值对象定义自己的自定义类型转换类。您可以选择将自定义类型转换类附加到模型,也可以将实现 Illuminate\Contracts\Database\Eloquent\Castable
接口的值对象类附加到模型:
use App\ValueObjects\Address;
protected function casts(): array
{
return [
'address' => Address::class,
];
}
实现 Castable
接口的对象必须定义一个 castUsing
方法,该方法返回负责从/到此可转换类进行转换的自定义类型转换类的类名:
<?php
namespace App\ValueObjects;
use Illuminate\Contracts\Database\Eloquent\Castable;
use App\Casts\Address as AddressCast;
class Address implements Castable
{
/**
* 获取在从/到此转换目标时使用的类型转换类的名称。
*
* @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',
];
}
可转换对象与匿名类型转换类
通过将“可转换对象”与 PHP 的 匿名类 结合使用,您可以将值对象及其类型转换逻辑定义为单个可转换对象。为此,从值对象的 castUsing
方法返回一个匿名类。该匿名类应实现 CastsAttributes
接口:
<?php
namespace App\ValueObjects;
use Illuminate\Contracts\Database\Eloquent\Castable;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
class Address implements Castable
{
// ...
/**
* 获取在从/到此转换目标时使用的类型转换器类。
*
* @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,
];
}
};
}
}