Laravel 13 JSON:API Resources 完全指南

摘要

Laravel 13 引入了第一方 JSON:API Resources,简化符合 JSON:API 规范的 API 构建。本文将全面解析 JSON:API Resources 的使用,包括:

  • JSON:API 规范核心概念
  • Laravel 13 JSON:API Resources 基础用法
  • 资源对象序列化
  • 关系包含与链接
  • 稀疏字段集
  • 分页与过滤
  • 错误处理

本文适合希望构建标准化 API 的 Laravel 开发者。

1. JSON:API 规范概述

1.1 什么是 JSON:API

JSON:API 是一种用于构建 API 的规范,定义了客户端如何请求和修改资源,以及服务器如何响应这些请求。主要特点:

  • 统一的资源对象格式
  • 标准化的关系处理
  • 内置分页支持
  • 错误响应规范
  • 内容协商

1.2 基本响应格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
"jsonapi": {
"version": "1.1"
},
"data": {
"type": "articles",
"id": "1",
"attributes": {
"title": "JSON:API Example",
"content": "This is an example."
},
"relationships": {
"author": {
"data": {"type": "users", "id": "9"}
}
},
"links": {
"self": "/api/articles/1"
}
}
}

2. 基础用法

2.1 创建 JSON:API Resource

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\JsonApiResource;

class ArticleResource extends JsonApiResource
{
public function toArray($request): array
{
return [
'title' => $this->title,
'content' => $this->content,
'published_at' => $this->published_at,
];
}
}

2.2 控制器使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php

namespace App\Http\Controllers\Api;

use App\Models\Article;
use App\Http\Resources\ArticleResource;

class ArticleController extends Controller
{
public function show(Article $article)
{
return new ArticleResource($article);
}

public function index()
{
return ArticleResource::collection(Article::all());
}
}

2.3 响应示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"jsonapi": {"version": "1.1"},
"data": {
"type": "articles",
"id": "1",
"attributes": {
"title": "My Article",
"content": "Article content...",
"published_at": "2026-03-20T10:00:00Z"
},
"links": {
"self": "http://example.com/api/articles/1"
}
}
}

3. 资源类型与 ID

3.1 自定义类型

1
2
3
4
5
6
7
class ArticleResource extends JsonApiResource
{
protected function resourceType(): string
{
return 'posts'; // 默认使用表名
}
}

3.2 自定义 ID

1
2
3
4
5
6
7
class ArticleResource extends JsonApiResource
{
protected function resourceId(): string
{
return $this->slug; // 使用 slug 作为 ID
}
}

4. 关系处理

4.1 定义关系

1
2
3
4
5
6
7
8
9
10
11
class ArticleResource extends JsonApiResource
{
public function relationships(): array
{
return [
'author' => $this->relation(UserResource::class, 'author'),
'comments' => $this->relation(CommentResource::class, 'comments'),
'tags' => $this->relation(TagResource::class, 'tags'),
];
}
}

4.2 包含关系

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 请求: GET /api/articles/1?include=author,comments

// 响应:
{
"data": {
"type": "articles",
"id": "1",
"attributes": {...},
"relationships": {
"author": {
"data": {"type": "users", "id": "9"}
},
"comments": {
"data": [
{"type": "comments", "id": "1"},
{"type": "comments", "id": "2"}
]
}
}
},
"included": [
{
"type": "users",
"id": "9",
"attributes": {"name": "John Doe"}
},
{
"type": "comments",
"id": "1",
"attributes": {"body": "Great article!"}
}
]
}

4.3 关系链接

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class ArticleResource extends JsonApiResource
{
public function relationships(): array
{
return [
'author' => $this->relation(UserResource::class, 'author')
->withLinks()
->withData(),
'comments' => $this->relation(CommentResource::class, 'comments')
->withLinks()
->withoutData(),
];
}
}

5. 稀疏字段集

5.1 基本用法

1
2
3
// 请求: GET /api/articles?fields[articles]=title,content

// 自动只返回指定字段

5.2 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class ArticleResource extends JsonApiResource
{
public function toArray($request): array
{
return [
'title' => $this->title,
'content' => $this->content,
'summary' => $this->summary,
'published_at' => $this->published_at,
];
}

protected function sparseFieldsetsEnabled(): bool
{
return true; // 默认启用
}
}

6. 分页

6.1 基本分页

1
2
3
4
5
6
7
8
9
class ArticleController extends Controller
{
public function index()
{
$articles = Article::paginate(15);

return ArticleResource::collection($articles);
}
}

6.2 分页响应

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"jsonapi": {"version": "1.1"},
"data": [...],
"links": {
"first": "/api/articles?page=1",
"last": "/api/articles?page=10",
"prev": "/api/articles?page=1",
"next": "/api/articles?page=3",
"self": "/api/articles?page=2"
},
"meta": {
"current_page": 2,
"from": 16,
"last_page": 10,
"per_page": 15,
"to": 30,
"total": 150
}
}

6.3 游标分页

1
2
3
4
5
6
public function index()
{
$articles = Article::cursorPaginate(15);

return ArticleResource::collection($articles);
}

7. 过滤与排序

7.1 过滤

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class ArticleController extends Controller
{
public function index(Request $request)
{
$query = Article::query();

if ($request->has('filter.status')) {
$query->where('status', $request->input('filter.status'));
}

if ($request->has('filter.author_id')) {
$query->where('author_id', $request->input('filter.author_id'));
}

return ArticleResource::collection($query->paginate());
}
}

7.2 排序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public function index(Request $request)
{
$sort = $request->input('sort', '-created_at'); // 默认按创建时间倒序

$query = Article::query();

if (str_starts_with($sort, '-')) {
$query->orderBy(substr($sort, 1), 'desc');
} else {
$query->orderBy($sort, 'asc');
}

return ArticleResource::collection($query->paginate());
}

8. 错误处理

8.1 错误响应

1
2
3
4
5
6
7
8
use Illuminate\Http\Resources\Json\JsonApiError;

return JsonApiError::make()
->status(404)
->code('RESOURCE_NOT_FOUND')
->title('Resource Not Found')
->detail('The requested article does not exist.')
->source('/data/id');

8.2 错误响应格式

1
2
3
4
5
6
7
8
9
10
11
12
{
"jsonapi": {"version": "1.1"},
"errors": [
{
"status": "404",
"code": "RESOURCE_NOT_FOUND",
"title": "Resource Not Found",
"detail": "The requested article does not exist.",
"source": {"pointer": "/data/id"}
}
]
}

8.3 验证错误

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
use Illuminate\Http\Resources\Json\JsonApiError;

public function store(Request $request)
{
$validator = Validator::make($request->all(), [
'title' => 'required|max:255',
'content' => 'required',
]);

if ($validator->fails()) {
$errors = JsonApiError::collection();

foreach ($validator->errors()->messages() as $field => $messages) {
foreach ($messages as $message) {
$errors->add(
JsonApiError::make()
->status(422)
->title('Validation Error')
->detail($message)
->source("/data/attributes/{$field}")
);
}
}

return $errors->response(422);
}
}

9. 完整示例

9.1 资源类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<?php

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\JsonApiResource;

class ArticleResource extends JsonApiResource
{
public function toArray($request): array
{
return [
'title' => $this->title,
'slug' => $this->slug,
'content' => $this->content,
'summary' => $this->summary,
'status' => $this->status,
'published_at' => $this->published_at,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}

public function relationships(): array
{
return [
'author' => $this->relation(UserResource::class, 'author')
->withLinks()
->withData(),
'comments' => $this->relation(CommentResource::class, 'comments')
->withLinks(),
'tags' => $this->relation(TagResource::class, 'tags')
->withLinks(),
];
}

public function links(): array
{
return [
'self' => route('api.articles.show', $this->id),
];
}
}

9.2 控制器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
<?php

namespace App\Http\Controllers\Api;

use App\Models\Article;
use App\Http\Resources\ArticleResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonApiError;

class ArticleController extends Controller
{
public function index(Request $request)
{
$query = Article::query()->with(['author', 'tags']);

// 过滤
if ($status = $request->input('filter.status')) {
$query->where('status', $status);
}

// 排序
$sort = $request->input('sort', '-created_at');
$direction = str_starts_with($sort, '-') ? 'desc' : 'asc';
$field = ltrim($sort, '-');
$query->orderBy($field, $direction);

// 包含关系
$include = $request->input('include', '');

return ArticleResource::collection($query->paginate())
->withIncludes($include);
}

public function show(Article $article, Request $request)
{
$article->load(['author', 'tags']);

return (new ArticleResource($article))
->withIncludes($request->input('include', ''));
}

public function store(Request $request)
{
$validated = $request->validate([
'data.attributes.title' => 'required|max:255',
'data.attributes.content' => 'required',
'data.attributes.status' => 'required|in:draft,published',
]);

$article = Article::create([
'title' => $validated['data']['attributes']['title'],
'content' => $validated['data']['attributes']['content'],
'status' => $validated['data']['attributes']['status'],
'author_id' => auth()->id(),
]);

return (new ArticleResource($article))
->response(201)
->header('Location', route('api.articles.show', $article->id));
}

public function update(Article $article, Request $request)
{
$validated = $request->validate([
'data.attributes.title' => 'sometimes|max:255',
'data.attributes.content' => 'sometimes',
'data.attributes.status' => 'sometimes|in:draft,published',
]);

$article->update($validated['data']['attributes'] ?? []);

return new ArticleResource($article);
}

public function destroy(Article $article)
{
$article->delete();

return response()->noContent();
}
}

9.3 路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// routes/api.php
use Illuminate\Routing\Attributes\Get;
use Illuminate\Routing\Attributes\Post;
use Illuminate\Routing\Attributes\Put;
use Illuminate\Routing\Attributes\Delete;
use Illuminate\Routing\Attributes\Prefix;

#[Prefix('api/v1')]
class ArticleRoutes
{
#[Get('/articles', name: 'api.articles.index')]
public function index() {}

#[Get('/articles/{article}', name: 'api.articles.show')]
public function show() {}

#[Post('/articles', name: 'api.articles.store')]
public function store() {}

#[Put('/articles/{article}', name: 'api.articles.update')]
public function update() {}

#[Delete('/articles/{article}', name: 'api.articles.destroy')]
public function destroy() {}
}

10. 最佳实践

10.1 资源命名

1
2
3
4
5
6
7
// 推荐:使用复数形式
'type' => 'articles'
'type' => 'users'

// 不推荐
'type' => 'article'
'type' => 'user'

10.2 版本控制

1
2
3
4
5
#[Prefix('api/v1')]
class ArticleRoutes {}

#[Prefix('api/v2')]
class ArticleRoutesV2 {}

10.3 缓存策略

1
2
3
4
5
6
7
8
public function show(Article $article)
{
return Cache::remember(
"article.{$article->id}.jsonapi",
now()->addHours(1),
fn() => new ArticleResource($article)
);
}

11. 总结

Laravel 13 的 JSON:API Resources 为构建标准化 API 提供了强大支持:

  1. 开箱即用:无需额外包即可使用
  2. 规范兼容:完全符合 JSON:API 规范
  3. 关系处理:简化复杂关系的序列化
  4. 分页支持:内置分页链接生成
  5. 错误处理:标准化的错误响应

通过本指南,您已经掌握了 JSON:API Resources 的核心用法,可以开始构建标准化 API 了。

参考资料