Lzh on GitHub

介绍

在构建 API 时,你可能需要一个位于 Eloquent 模型与最终返回给应用用户的 JSON 响应之间的转换层。例如,你可能希望只为特定用户显示某些属性,或者希望在模型的 JSON 表示中始终包含某些关联关系。Eloquent 的资源类可以让你以清晰、优雅的方式,将模型及模型集合转换为 JSON。

当然,你也可以直接使用模型或集合的 toJson 方法来转换为 JSON;但 Eloquent 资源提供了更细粒度、更强大的控制,使你能够更灵活地管理模型及其关联关系的 JSON 序列化。

生成资源

要生成资源类,你可以使用 Artisan 命令 make:resource。默认情况下,资源类会被放置在应用的 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));
});

为了方便,也可以使用模型的 toResource 方法,它会按照框架约定自动发现模型对应的资源:

return User::findOrFail($id)->toResource();

调用 toResource 方法时,Laravel 会尝试在最接近模型命名空间的 Http\Resources 命名空间中,查找与模型名称匹配(可选带有 Resource 后缀)的资源类。

如果你的资源类不遵循此命名约定,或者位于不同的命名空间,可以使用 UseResource 特性为模型指定默认资源:

<?php

namespace App\Models;

use App\Http\Resources\CustomUserResource;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Attributes\UseResource;

#[UseResource(CustomUserResource::class)]
class User extends Model
{
    // ...
}

或者,你也可以在调用 toResource 方法时显式传入资源类:

return User::findOrFail($id)->toResource(CustomUserResource::class);

资源集合

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

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

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

或者,为了方便,也可以使用 Eloquent 集合的 toResourceCollection 方法,它会按照框架约定自动发现模型对应的资源集合:

return User::all()->toResourceCollection();

调用 toResourceCollection 方法时,Laravel 会尝试在最接近模型命名空间的 Http\Resources 命名空间中,查找与模型名称匹配并以 Collection 结尾的资源集合类。

如果你的资源集合类不遵循此命名约定,或者位于不同的命名空间,可以使用 UseResourceCollection 特性为模型指定默认资源集合:

<?php

namespace App\Models;

use App\Http\Resources\CustomUserCollection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Attributes\UseResourceCollection;

#[UseResourceCollection(CustomUserCollection::class)]
class User extends Model
{
    // ...
}

或者,你也可以在调用 toResourceCollection 方法时显式传入资源集合类:

return User::all()->toResourceCollection(CustomUserCollection::class);

自定义资源集合

默认情况下,资源集合不允许附加自定义元数据(meta data)。如果希望自定义资源集合的响应,可以创建专门的资源来表示集合:

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

或者使用 Eloquent 集合的 toResourceCollection 方法自动发现资源集合:

return User::all()->toResourceCollection();

保留集合键

当从路由返回资源集合时,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\Models\User;

Route::get('/user/{id}', function (string $id) {
    return User::findOrFail($id)->toUserResource();
});

关联关系

如果希望在响应中包含关联资源,可以将其添加到 toArray 方法返回的数组中。例如,使用 PostResourcecollection 方法将用户的博客文章加入资源响应:

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,
    ];
}
如果希望只在关联关系已经加载时才包含它们,可以参考文档中关于 条件关联 的部分。

资源集合

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

use App\Models\User;

Route::get('/users', function () {
    return User::all()->toResourceCollection();
});

然而,如果需要自定义集合响应中返回的元数据,就必须定义自己的资源集合类:

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

或者为了方便,也可以使用 Eloquent 集合的 toResourceCollection 方法,框架会自动发现模型对应的资源集合:

return User::all()->toResourceCollection();

调用 toResourceCollection 方法时,Laravel 会尝试在最接近模型命名空间的 Http\Resources 命名空间中,查找与模型名称匹配并以 Collection 结尾的资源集合类。

数据封装

默认情况下,当资源响应被转换为 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();
    }
}
withoutWrapping 方法仅影响最外层响应,不会移除你在自定义资源集合中手动添加的 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());
});

或者,为了方便,也可以使用分页器的 toResourceCollection 方法,它会按照框架约定自动发现分页模型对应的资源集合:

return User::paginate()->toResourceCollection();

分页响应总是包含 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,
    ];
}

在此示例中,secret 键只有在认证用户的 isAdmin 方法返回 true 时才会包含在最终的资源响应中;如果返回 false,该键会在发送给客户端之前被移除。when 方法可以让你在构建数组时优雅地定义资源,而无需使用条件语句。

when 方法的第二个参数也可以是闭包,这样只有在条件为真时才会计算属性值:

'secret' => $this->when($request->user()->isAdmin(), function () {
    return 'secret-value';
}),
  • whenHas 方法可用于仅在底层模型中实际存在该属性时才包含它:
'name' => $this->whenHas('name'),
  • whenNotNull 方法可用于仅在属性非 null 时才包含它:
'name' => $this->whenNotNull($this->name),

合并条件属性

有时你可能有多个属性,它们都应该在同一个条件满足时才包含在资源响应中。这时可以使用 mergeWhen 方法,在条件为真时将这些属性合并到响应中:

/**
 * 将资源转换为数组。
 *
 * @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' => 'value',
            'second-secret' => 'value',
        ]),
        'created_at' => $this->created_at,
        'updated_at' => $this->updated_at,
    ];
}

同样地,如果条件为 false,这些属性会在发送给客户端之前被移除。

  • 不应在同时混合字符串和数字键的数组中使用 mergeWhen
  • 对于非顺序排列的数字键数组,也不应使用 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 键会在发送给客户端前被移除。

其他聚合类型,如 avgsumminmax 也可以使用 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 方法条件性地包含多对多关联的中间表数据。该方法的第一个参数是中间表名称,第二个参数是闭包,用于返回中间表信息可用时的值:

/**
 * 将资源转换为数组。
 *
 * @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 标准要求在资源和资源集合响应中添加元数据(meta data),这通常包括指向资源或相关资源的链接,或关于资源本身的元信息。如果你需要返回资源的额外元数据,可以在 toArray 方法中包含这些信息。例如,在转换资源集合时,你可能会包含链接信息:

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

当从资源返回额外的元数据时,你无需担心会意外覆盖 Laravel 在返回分页响应时自动添加的 linksmeta 键。你定义的额外链接会与分页器提供的链接合并。

顶层元数据

有时你可能希望仅在资源是最外层返回的资源时才包含某些元数据,这通常包括关于整个响应的元信息。要定义这些元数据,可以在资源类中添加 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' => 'value',
            ],
        ];
    }
}

构建资源时添加元数据

你还可以在路由或控制器中构建资源实例时添加顶层数据。所有资源都可使用 additional 方法,该方法接受一个数组,将这些数据添加到资源响应中:

return User::all()
    ->load('roles')
    ->toResourceCollection()
    ->additional(['meta' => [
        'key' => 'value',
    ]]);

资源响应

如你所见,资源可以直接从路由或控制器中返回:

use App\Models\User;

Route::get('/user/{id}', function (string $id) {
    return User::findOrFail($id)->toResource();
});

然而,有时你可能希望在响应发送给客户端之前,自定义即将返回的 HTTP 响应。有两种方式可以实现这一点。

方式一:链式调用 response 方法

你可以在资源实例上链式调用 response 方法。该方法会返回一个 Illuminate\Http\JsonResponse 实例,让你可以完全控制响应头:

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

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

方式二:在资源类中定义 withResponse 方法

你也可以在资源类中定义 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');
    }
}