laravel
8. Eloquent ORM
Eloquent:API 资源

介绍

在构建 API 时,您可能需要一个位于 Eloquent 模型和实际返回给应用程序用户的 JSON 响应之间的转换层。例如,您可能希望为一部分用户显示某些属性,而不为其他用户显示,或者您可能希望在模型的 JSON 表示中始终包含某些关系。Eloquent 的资源类允许您以明确且轻松的方式将模型和模型集合转换为 JSON。

当然,您始终可以使用 Eloquent 模型或集合的toJson方法将其转换为 JSON;然而,Eloquent 资源为您的模型及其关系的 JSON 序列化提供了更精细和强大的控制。

生成资源

要生成资源类,可以使用make:resource Artisan 命令。默认情况下,资源将放置在应用程序的app/Http/Resources目录中。资源扩展了Illuminate\Http\Resources\Json\JsonResource类:

php artisan make:resource UserResource

资源集合

除了生成转换单个模型的资源外,您还可以生成负责转换模型集合的资源。这允许您的 JSON 响应包含与给定资源的整个集合相关的链接和其他元信息。

要创建资源集合,在创建资源时应使用--collection标志。或者,在资源名称中包含Collection一词将向 Laravel 表明它应该创建一个集合资源。集合资源扩展了Illuminate\Http\Resources\Json\ResourceCollection类:

php artisan make:resource User --collection
 
php artisan make:resource UserCollection

概念概述

[!注意]
这是资源和资源集合的高级概述。强烈建议您阅读本文档的其他部分,以更深入地了解资源为您提供的自定义和功能。

在深入探讨编写资源时可用的所有选项之前,让我们首先从高层次了解资源在 Laravel 中的使用方式。资源类表示需要转换为 JSON 结构的单个模型。例如,这里是一个简单的UserResource资源类:

<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class UserResource extends JsonResource
{
    /**
     * 将资源转换为数组。
     *
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'email' => $this->email,
            'created_at' => $this->created_at,
            'updated_at' => $this->updated_at,
        ];
    }
}

每个资源类都定义了一个toArray方法,该方法返回当资源作为路由或控制器方法的响应返回时应转换为 JSON 的属性数组。

请注意,我们可以直接从$this变量访问模型属性。这是因为资源类会自动将属性和方法访问代理到基础模型,以便于访问。一旦定义了资源,就可以从路由或控制器中返回它:

use App\Http\Resources\UserResource;
use App\Models\User;

Route::get('/user/{id}', function (string $id) {
    return new UserResource(User::findOrFail($id));
});

资源集合

如果您要返回资源集合或分页响应,则在路由或控制器中创建资源实例时,应使用资源类提供的collection方法:

use App\Http\Resources\UserResource;
use App\Models\User;

Route::get('/users', function () {
    return UserResource::collection(User::all());
});

请注意,这不允许添加可能需要与您的集合一起返回的任何自定义元数据。如果您想自定义资源集合响应,可以创建一个专门的资源来表示该集合:

php artisan make:resource UserCollection

一旦生成了资源集合类,您可以轻松定义应与响应一起包含的任何元数据:

<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;

class UserCollection extends ResourceCollection
{
    /**
     * 将资源集合转换为数组。
     *
     * @return array<int|string, mixed>
     */
    public function toArray(Request $request): array
    {
        return [
            'data' => $this->collection,
            'links' => [
                'self' => 'link-value',
            ],
        ];
    }
}

定义了资源集合后,可以从路由或控制器中返回它:

use App\Http\Resources\UserCollection;
use App\Models\User;

Route::get('/users', function () {
    return new UserCollection(User::all());
});

保留集合键

从路由返回资源集合时,Laravel 会重置集合的键,使其按数字顺序排列。但是,您可以向资源类添加一个preserveKeys属性,以指示是否应保留集合的原始键:

<?php

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\JsonResource;

class UserResource extends JsonResource
{
    /**
     * 指示是否应保留资源集合的键。
     *
     * @var bool
     */
    public $preserveKeys = true;
}

preserveKeys属性设置为true时,从路由或控制器返回集合时,集合键将被保留:

use App\Http\Resources\UserResource;
use App\Models\User;

Route::get('/users', function () {
    return UserResource::collection(User::all()->keyBy->id);
});

自定义基础资源类

通常,资源集合的$this->collection属性会自动使用将集合的每个项目映射到其单数资源类的结果进行填充。单数资源类假定为集合的类名,不包括类名的尾随Collection部分。此外,根据您的个人喜好,单数资源类可能会或可能不会以Resource为后缀。

例如,UserCollection将尝试将给定的用户实例映射到UserResource资源。要自定义此行为,您可以覆盖资源集合的$collects属性:

<?php

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\ResourceCollection;

class UserCollection extends ResourceCollection
{
    /**
     * 此资源收集的资源。
     *
     * @var string
     */
    public $collects = Member::class;
}

编写资源

[!注意]
如果您尚未阅读概念概述,强烈建议您在继续阅读本文档之前先阅读该部分。

资源只需要将给定的模型转换为数组。因此,每个资源都包含一个toArray方法,该方法将模型的属性转换为可以从应用程序的路由或控制器中返回的 API 友好型数组:

<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class UserResource extends JsonResource
{
    /**
     * 将资源转换为数组。
     *
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'email' => $this->email,
            'created_at' => $this->created_at,
            'updated_at' => $this->updated_at,
        ];
    }
}

一旦定义了资源,就可以直接从路由或控制器中返回它:

use App\Http\Resources\UserResource;
use App\Models\User;

Route::get('/user/{id}', function (string $id) {
    return new UserResource(User::findOrFail($id));
});

关系

如果您想在响应中包含相关资源,可以将它们添加到资源的toArray方法返回的数组中。在这个例子中,我们将使用PostResource资源的collection方法将用户的博客文章添加到资源响应中:

use App\Http\Resources\PostResource;
use Illuminate\Http\Request;

/**
 * 将资源转换为数组。
 *
 * @return array<string, mixed>
 */
public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'name' => $this->name,
        'email' => $this->email,
        'posts' => PostResource::collection($this->posts),
        'created_at' => $this->created_at,
        'updated_at' => $this->updated_at,
    ];
}

[!注意]
如果您只想在关系已经加载的情况下包含它们,请查看条件关系的文档。

资源集合

虽然资源将单个模型转换为数组,但资源集合将模型集合转换为数组。然而,并非必须为每个模型都定义一个资源集合类,因为所有资源都提供了一个collection方法,可用于即时生成“临时”资源集合:

use App\Http\Resources\UserResource;
use App\Models\User;

Route::get('/users', function () {
    return UserResource::collection(User::all());
});

但是,如果您需要自定义与集合一起返回的元数据,则有必要定义自己的资源集合:

<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;

class UserCollection extends ResourceCollection
{
    /**
     * 将资源集合转换为数组。
     *
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        return [
            'data' => $this->collection,
            'links' => [
                'self' => 'link-value',
            ],
        ];
    }
}

与单一资源一样,资源集合可以直接从路由或控制器中返回:

use App\Http\Resources\UserCollection;
use App\Models\User;

Route::get('/users', function () {
    return new UserCollection(User::all());
});

数据包装

默认情况下,当资源响应转换为 JSON 时,最外层资源会包装在一个data键中。例如,典型的资源集合响应如下所示:

{
    "data": [
        {
            "id": 1,
            "name": "Eladio Schroeder Sr.",
            "email": "therese28@example.com"
        },
        {
            "id": 2,
            "name": "Liliana Mayert",
            "email": "evandervort@example.com"
        }
    ]
}

如果您想要禁用最外层资源的包装,您应该在基础的Illuminate\Http\Resources\Json\JsonResource类上调用withoutWrapping方法。通常,您应该在您的AppServiceProvider或其他服务提供者中调用此方法,该服务提供者会在对您的应用程序的每个请求中加载:

<?php

namespace App\Providers;

use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    /**
     * 注册任何应用程序服务。
     */
    public function register(): void
    {
        //...
    }

    /**
     * 引导任何应用程序服务。
     */
    public function boot(): void
    {
        JsonResource::withoutWrapping();
    }
}

[!WARNING]
withoutWrapping方法仅影响最外层响应,不会删除您手动添加到自己的资源集合中的data键。

包装嵌套资源

您完全可以自由决定如何包装资源的关系。如果您希望所有资源集合都包装在一个data键中,无论其嵌套情况如何,您应该为每个资源定义一个资源集合类,并在data键中返回该集合。

您可能会想这是否会导致最外层资源被包装在两个data键中。不用担心,Laravel 永远不会让您的资源意外地被双重包装,因此您不必担心正在转换的资源集合的嵌套级别:

<?php

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\ResourceCollection;

class CommentsCollection extends ResourceCollection
{
    /**
     * 将资源集合转换为数组。
     *
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        return ['data' => $this->collection];
    }
}

数据包装和分页

当通过资源响应返回分页集合时,即使调用了withoutWrapping方法,Laravel 也会将您的资源数据包装在一个data键中。这是因为分页响应始终包含有关分页器状态的metalinks键:

{
    "data": [
        {
            "id": 1,
            "name": "Eladio Schroeder Sr.",
            "email": "therese28@example.com"
        },
        {
            "id": 2,
            "name": "Liliana Mayert",
            "email": "evandervort@example.com"
        }
    ],
    "links":{
        "first": "http://example.com/users?page=1",
        "last": "http://example.com/users?page=1",
        "prev": null,
        "next": null
    },
    "meta":{
        "current_page": 1,
        "from": 1,
        "last_page": 1,
        "path": "http://example.com/users",
        "per_page": 15,
        "to": 10,
        "total": 10
    }
}

分页

您可以将 Laravel 分页器实例传递给资源的collection方法或自定义资源集合:

use App\Http\Resources\UserCollection;
use App\Models\User;

Route::get('/users', function () {
    return new UserCollection(User::paginate());
});

分页响应始终包含有关分页器状态的metalinks键:

{
    "data": [
        {
            "id": 1,
            "name": "Eladio Schroeder Sr.",
            "email": "therese28@example.com"
        },
        {
            "id": 2,
            "name": "Liliana Mayert",
            "email": "evandervort@example.com"
        }
    ],
    "links":{
        "first": "http://example.com/users?page=1",
        "last": "http://example.com/users?page=1",
        "prev": null,
        "next": null
    },
    "meta":{
        "current_page": 1,
        "from": 1,
        "last_page": 1,
        "path": "http://example.com/users",
        "per_page": 15,
        "to": 10,
        "total": 10
    }
}

自定义分页信息

如果您想要自定义分页响应的linksmeta键中包含的信息,您可以在资源上定义一个paginationInformation方法。此方法将接收$paginated数据和$default信息数组,该数组包含linksmeta键:

/**
 * 为资源自定义分页信息。
 *
 * @param  \Illuminate\Http\Request  $request
 * @param  array $paginated
 * @param  array $default
 * @return array
 */
public function paginationInformation($request, $paginated, $default)
{
    $default['links']['custom'] = 'https://example.com';

    return $default;
}

条件属性

有时您可能希望仅在满足给定条件时才在资源响应中包含某个属性。例如,您可能希望仅在当前用户是“管理员”时才包含某个值。Laravel 提供了多种辅助方法来帮助您解决这种情况。when方法可用于有条件地向资源响应添加属性:

/**
 * 将资源转换为数组。
 *
 * @return array<string, mixed>
 */
public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'name' => $this->name,
        'email' => $this->email,
        'secret' => $this->when($request->user()->isAdmin(), 'secret-value'),
        'created_at' => $this->created_at,
        'updated_at' => $this->updated_at,
    ];
}

在此示例中,如果经过身份验证的用户的isAdmin方法返回true,则secret键将仅在最终资源响应中返回。如果该方法返回false,则在将资源响应发送到客户端之前,secret键将从资源响应中删除。when方法允许您在构建数组时明确地定义资源,而无需诉诸条件语句。

when方法还可以接受一个闭包作为其第二个参数,仅在给定条件为true时才计算结果值:

'secret' => $this->when($request->user()->isAdmin(), function () {
    return 'secret-value';
}),

whenHas方法可用于在基础模型上实际存在某个属性时包含该属性:

'name' => $this->whenHas('name'),

此外,whenNotNull方法可用于在资源响应中包含某个属性,如果该属性不为空:

'name' => $this->whenNotNull($this->name),

合并条件属性

有时,您可能有几个属性,只有在相同条件下才应包含在资源响应中。在这种情况下,您可以使用 mergeWhen 方法,仅当给定条件为 true 时,才将这些属性包含在响应中:

/**
 * 将资源转换为数组。
 *
 * @return array<string, mixed>
 */
public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'name' => $this->name,
        'email' => $this->email,
        $this->mergeWhen($request->user()->isAdmin(), [
            'first-secret' => '值',
            'second-secret' => '值',
        ]),
        'created_at' => $this->created_at,
        'updated_at' => $this->updated_at,
    ];
}

同样,如果给定条件为 false,则在将资源响应发送到客户端之前,这些属性将从资源响应中删除。

[!WARNING]
mergeWhen 方法不应在混合使用字符串和数字键的数组中使用。此外,它也不应在数字键不是按顺序排列的数组中使用。

条件关系

除了有条件地加载属性外,您还可以根据模型上是否已加载关系,有条件地在资源响应中包含关系。这允许您的控制器决定在模型上应加载哪些关系,并且您的资源可以在实际加载它们时轻松地将它们包含在内。最终,这使得在您的资源中更容易避免“N + 1”查询问题。

可以使用 whenLoaded 方法有条件地加载关系。为了避免不必要地加载关系,此方法接受关系的名称而不是关系本身:

use App\Http\Resources\PostResource;

/**
 * 将资源转换为数组。
 *
 * @return array<string, mixed>
 */
public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'name' => $this->name,
        'email' => $this->email,
        'posts' => PostResource::collection($this->whenLoaded('posts')),
        'created_at' => $this->created_at,
        'updated_at' => $this->updated_at,
    ];
}

在此示例中,如果关系未加载,则在将资源响应发送到客户端之前,posts 键将从资源响应中删除。

条件关系计数

除了有条件地包含关系外,您还可以根据模型上是否已加载关系的计数,有条件地在资源响应中包含关系“计数”:

new UserResource($user->loadCount('posts'));

可以使用 whenCounted 方法有条件地在资源响应中包含关系的计数。如果关系的计数不存在,此方法将避免不必要地包含该属性:

/**
 * 将资源转换为数组。
 *
 * @return array<string, mixed>
 */
public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'name' => $this->name,
        'email' => $this->email,
        'posts_count' => $this->whenCounted('posts'),
        'created_at' => $this->created_at,
        'updated_at' => $this->updated_at,
    ];
}

在此示例中,如果 posts 关系的计数未加载,则在将资源响应发送到客户端之前,posts_count 键将从资源响应中删除。

其他类型的聚合,如 avg(平均值)、sum(总和)、min(最小值)和 max(最大值),也可以使用 whenAggregated 方法有条件地加载:

'words_avg' => $this->whenAggregated('posts', 'words', 'avg'),
'words_sum' => $this->whenAggregated('posts', 'words', 'sum'),
'words_min' => $this->whenAggregated('posts', 'words', 'min'),
'words_max' => $this->whenAggregated('posts', 'words', 'max'),

条件枢纽信息

除了在资源响应中有条件地包含关系信息外,您还可以使用 whenPivotLoaded 方法有条件地包含多对多关系中间表中的数据。whenPivotLoaded 方法的第一个参数是枢纽表的名称。第二个参数应该是一个闭包,如果模型上有可用的枢纽信息,该闭包将返回要返回的值:

/**
 * 将资源转换为数组。
 *
 * @return array<string, mixed>
 */
public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'name' => $this->name,
        'expires_at' => $this->whenPivotLoaded('role_user', function () {
            return $this->pivot->expires_at;
        }),
    ];
}

如果您的关系使用 自定义中间表模型,您可以将中间表模型的实例作为第一个参数传递给 whenPivotLoaded 方法:

'expires_at' => $this->whenPivotLoaded(new Membership, function () {
    return $this->pivot->expires_at;
}),

如果您的中间表使用的访问器不是 pivot,您可以使用 whenPivotLoadedAs 方法:

/**
 * 将资源转换为数组。
 *
 * @return array<string, mixed>
 */
public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'name' => $this->name,
        'expires_at' => $this->whenPivotLoadedAs('subscription', 'role_user', function () {
            return $this->subscription->expires_at;
        }),
    ];
}

添加元数据

一些 JSON API 标准要求在您的资源和资源集合响应中添加元数据。这通常包括诸如资源或相关资源的 链接,或关于资源本身的元数据等内容。如果您需要返回有关资源的其他元数据,请将其包含在您的 toArray 方法中。例如,在转换资源集合时,您可能会包含 链接 信息:

/**
 * 将资源转换为数组。
 *
 * @return array<string, mixed>
 */
public function toArray(Request $request): array
{
    return [
        'data' => $this->collection,
        'links' => [
            'self' => '链接值',
        ],
    ];
}

当从您的资源中返回其他元数据时,您永远不必担心意外覆盖 Laravel 在返回分页响应时自动添加的 链接元数据 键。您定义的任何其他 链接 都将与分页器提供的链接合并。

顶级元数据

有时,您可能希望仅在资源是正在返回的最外层资源时,才在资源响应中包含某些元数据。通常,这包括有关整个响应的元信息。要定义此元数据,请在您的资源类中添加一个 with 方法。此方法应在资源是正在转换的最外层资源时,返回一个要与资源响应一起包含的元数据数组:

<?php

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\ResourceCollection;

class UserCollection extends ResourceCollection
{
    /**
     * 将资源集合转换为数组。
     *
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        return parent::toArray($request);
    }

    /**
     * 获取应与资源数组一起返回的其他数据。
     *
     * @return array<string, mixed>
     */
    public function with(Request $request): array
    {
        return [
            'meta' => [
                'key' => '值',
            ],
        ];
    }
}

在构造资源时添加元数据

您还可以在您的路由或控制器中构造资源实例时添加顶级数据。所有资源都可用的 additional 方法接受一个应添加到资源响应中的数据数组:

return (new UserCollection(User::all()->load('roles')))
                ->additional(['meta' => [
                    'key' => '值',
                ]]);

资源响应

如您已经阅读过的,资源可以直接从路由和控制器中返回:

use App\Http\Resources\UserResource;
use App\Models\User;

Route::get('/user/{id}', function (string $id) {
    return new UserResource(User::findOrFail($id));
});

然而,有时您可能需要在将传出的 HTTP 响应发送到客户端之前对其进行自定义。有两种方法可以实现此目的。首先,您可以将 response 方法链接到资源上。此方法将返回一个 Illuminate\Http\JsonResponse 实例,使您可以完全控制响应的标头:

use App\Http\Resources\UserResource;
use App\Models\User;

Route::get('/user', function () {
    return (new UserResource(User::find(1)))
                ->response()
                ->header('X-Value', 'True');
});

或者,您可以在资源本身内定义一个 withResponse 方法。当资源作为响应中的最外层资源返回时,将调用此方法:

<?php

namespace App\Http\Resources;

use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class UserResource extends JsonResource
{
    /**
     * 将资源转换为数组。
     *
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
        ];
    }

    /**
     * 为资源自定义传出响应。
     */
    public function withResponse(Request $request, JsonResponse $response): void
    {
        $response->header('X-Value', 'True');
    }
}