laravel
8. Eloquent ORM
Eloquent:修改器与类型转换

简介

访问器、修改器和属性类型转换允许您在从模型实例中获取或设置Eloquent属性值时对其进行转换。例如,您可能希望使用Laravel加密器在将值存储在数据库中时对其进行加密,然后在通过Eloquent模型访问该属性时自动对其进行解密。或者,您可能希望在通过Eloquent模型访问存储在数据库中的JSON字符串时将其转换为数组。

访问器与修改器

定义访问器

访问器会在访问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实例,该实例定义了如何访问属性以及(可选)如何修改属性。在这个例子中,我们只定义了如何访问属性。为此,我们将get参数提供给Attribute类的构造函数。

如您所见,列的原始值将传递给访问器,允许您操作并返回该值。要访问访问器的值,您可以简单地在模型实例上访问first_name属性:

use App\Models\User;

$user = User::find(1);

$firstName = $user->first_name;

[!NOTE]
如果您希望这些计算值被添加到模型的数组/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 = '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方法:

/**
 * 与用户的地址进行交互。
 */
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:<精度>
  • double
  • encrypted
  • encrypted:array
  • encrypted:collection
  • encrypted:object
  • float
  • hashed
  • integer
  • object
  • real
  • string
  • timestamp

为了演示属性类型转换,让我们将is_admin属性(在我们的数据库中存储为整数(01))转换为布尔值:

<?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',
]);

[!WARNING]
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 形式存储的列时特别有用。例如,如果您的数据库中有一个 JSONTEXT 字段类型,其中包含序列化的 JSON,将 array 类型转换添加到该属性上,当您在 Eloquent 模型上访问该属性时,它将自动将该属性从 JSON 反序列化为 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 (opens in a new tab) 类。此功能是使用 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_atupdated_at 列转换为 Carbon (opens in a new tab) 的实例,Carbon 扩展了 PHP 的 DateTime 类并提供了各种有用的方法。您可以通过在模型的 casts 方法中定义其他日期类型转换来转换其他日期属性。通常,日期应使用 datetimeimmutable_datetime 类型转换类型进行转换。

在定义 datedatetime 类型转换时,您还可以指定日期的格式。当模型序列化为数组或 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';

日期类型转换、序列化和时区

默认情况下,datedatetime 类型转换将日期序列化为 UTC ISO-8601 日期字符串(YYYY-MM-DDTHH:MM:SS.uuuuuuZ),无论您的应用程序的 timezone 配置选项中指定的时区是什么。强烈建议您始终使用此序列化格式,并通过不将应用程序的 timezone 配置选项从其默认的 UTC 值进行更改,将应用程序的日期存储在 UTC 时区中。在整个应用程序中始终使用 UTC 时区将为使用 PHP 和 JavaScript 编写的其他日期操作库提供最大程度的互操作性。

如果对 datedatetime 类型转换应用了自定义格式,例如 datetime:Y-m-d H:i:s,则在日期序列化期间将使用 Carbon 实例的内部时区。通常,这将是您的应用程序的 timezone 配置选项中指定的时区。但是,需要注意的是,像 created_atupdated_at 这样的 timestamp 列不受此行为的影响,并且始终以 UTC 格式进行格式化,无论应用程序的时区设置如何。

枚举类型转换

Eloquent 还允许您将属性值转换为 PHP 枚举 (opens in a new tab)。要实现此目的,您可以在模型的 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 提供的 AsEnumArrayObjectAsEnumCollection 类型转换:

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:arrayencrypted:collectionencrypted:objectAsEncryptedArrayObjectAsEncryptedCollection 类型转换的工作方式与它们未加密的对应类型类似;但是,正如您可能预期的那样,在数据库中存储时,基础值是加密的。

由于加密文本的最终长度是不可预测的,并且比其明文对应物更长,因此请确保相关的数据库列是 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 接口。实现此接口的类必须定义一个 getset 方法。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 值有两个公共属性:lineOnelineTwo

<?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();

[!NOTE]
如果您计划将包含值对象的 Eloquent 模型序列化为 JSON 或数组,您应该在值对象上实现 Illuminate\Contracts\Support\ArrayableJsonSerializable 接口。

值对象缓存

当转换为值对象的属性被解析时,它们会被 Eloquent 缓存。因此,如果再次访问该属性,将返回相同的对象实例。

如果您想要禁用自定义转换类的对象缓存行为,您可以在自定义转换类上声明一个公共的 withoutObjectCaching 属性:

class Address implements CastsAttributes
{
    public bool $withoutObjectCaching = true;
 
    //...
}

数组 / JSON 序列化

当使用 toArraytoJson 方法将 Eloquent 模型转换为数组或 JSON 时,您的自定义转换值对象通常也会被序列化,只要它们实现了 Illuminate\Contracts\Support\ArrayableJsonSerializable 接口。然而,当使用第三方库提供的值对象时,您可能无法向该对象添加这些接口。

因此,您可以指定您的自定义转换类将负责序列化值对象。为此,您的自定义转换类应该实现 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 方法,该方法返回负责在 Castable 类之间进行转换的自定义转换类的类名:

<?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 的匿名类 (opens in a new tab)结合使用,您可以将值对象及其转换逻辑定义为一个单一的可转换对象。要实现这一点,从您的值对象的 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,
                ];
            }
        };
    }
}