4. Relationships
Introduction
In this chapter, we are going to:
- add relationships to our
posts
resource. - add JSON:API relationship endpoints and access the values of the relationships via these endpoints; and
- use the JSON:API
include
query parameter to eager load the post's relationships in a single request.
Relationship Fields
When we created our Post
model, we added a number of relationships to it - namely the author
, the tags
and the comments
. Now we need to add these relationships to our posts
resource.
Laravel JSON:API provides relationship fields that match all of the Eloquent relationships. All we need to do is add these fields to our PostSchema
class, in the fields()
method.
Make the following changes to your app/JsonApi/V1/Posts/PostSchema.php
file:
namespace App\JsonApi\V1\Posts;
use App\Models\Post;
use LaravelJsonApi\Eloquent\Contracts\Paginator;
use LaravelJsonApi\Eloquent\Fields\DateTime;
use LaravelJsonApi\Eloquent\Fields\ID;
+use LaravelJsonApi\Eloquent\Fields\Relations\BelongsTo;
+use LaravelJsonApi\Eloquent\Fields\Relations\BelongsToMany;
+use LaravelJsonApi\Eloquent\Fields\Relations\HasMany;
use LaravelJsonApi\Eloquent\Fields\Str;
use LaravelJsonApi\Eloquent\Filters\WhereIdIn;
use LaravelJsonApi\Eloquent\Pagination\PagePagination;
use LaravelJsonApi\Eloquent\Schema;
class PostSchema extends Schema
{
/**
* The model the schema corresponds to.
*
* @var string
*/
public static string $model = Post::class;
/**
* Get the resource fields.
*
* @return array
*/
public function fields(): array
{
return [
ID::make(),
+ BelongsTo::make('author')->type('users')->readOnly(),
+ HasMany::make('comments')->readOnly(),
Str::make('content'),
DateTime::make('createdAt')->sortable()->readOnly(),
DateTime::make('publishedAt')->sortable(),
Str::make('slug'),
+ BelongsToMany::make('tags'),
Str::make('title')->sortable(),
DateTime::make('updatedAt')->sortable()->readOnly(),
];
}
/**
* Get the resource filters.
*
* @return array
*/
public function filters(): array
{
return [
WhereIdIn::make($this),
];
}
/**
* Get the resource paginator.
*
* @return Paginator|null
*/
public function pagination(): ?Paginator
{
return PagePagination::make();
}
}
As you can see, it's easy to add relationships.
Related Schemas
As these relationships will return User
, Comment
and Tag
models, we now need to create schemas for all three models. This is so that when Laravel JSON:API encounters these models, it knows how to serialise them to JSON:API resources.
Create the schemas by running the following commands:
vendor/bin/sail artisan jsonapi:schema comments
vendor/bin/sail artisan jsonapi:schema tags
vendor/bin/sail artisan jsonapi:schema user
This will create the following three files:
app/JsonApi/V1/Comments/CommentSchema.php
app/JsonApi/V1/Tags/TagSchema.php
app/JsonApi/V1/Users/UserSchema.php
Just as we did with our PostSchema
, we need to add fields to each of these schemas so that the resources have the correct fields when serialising to JSON.
Make the following changes to the CommentSchema
:
namespace App\JsonApi\V1\Comments;
use App\Models\Comment;
use LaravelJsonApi\Eloquent\Contracts\Paginator;
use LaravelJsonApi\Eloquent\Fields\DateTime;
use LaravelJsonApi\Eloquent\Fields\ID;
+use LaravelJsonApi\Eloquent\Fields\Relations\BelongsTo;
+use LaravelJsonApi\Eloquent\Fields\Str;
use LaravelJsonApi\Eloquent\Filters\WhereIdIn;
use LaravelJsonApi\Eloquent\Pagination\PagePagination;
use LaravelJsonApi\Eloquent\Schema;
class CommentSchema extends Schema
{
/**
* The model the schema corresponds to.
*
* @var string
*/
public static string $model = Comment::class;
/**
* Get the resource fields.
*
* @return array
*/
public function fields(): array
{
return [
ID::make(),
+ Str::make('content'),
DateTime::make('createdAt')->sortable()->readOnly(),
DateTime::make('updatedAt')->sortable()->readOnly(),
+ BelongsTo::make('user')->readOnly(),
];
}
/**
* Get the resource filters.
*
* @return array
*/
public function filters(): array
{
return [
WhereIdIn::make($this),
];
}
/**
* Get the resource paginator.
*
* @return Paginator|null
*/
public function pagination(): ?Paginator
{
return PagePagination::make();
}
}
Then our TagSchema
needs the following changes:
namespace App\JsonApi\V1\Tags;
use App\Models\Tag;
use LaravelJsonApi\Eloquent\Contracts\Paginator;
use LaravelJsonApi\Eloquent\Fields\DateTime;
use LaravelJsonApi\Eloquent\Fields\ID;
+use LaravelJsonApi\Eloquent\Fields\Str;
use LaravelJsonApi\Eloquent\Filters\WhereIdIn;
use LaravelJsonApi\Eloquent\Pagination\PagePagination;
use LaravelJsonApi\Eloquent\Schema;
class TagSchema extends Schema
{
/**
* The model the schema corresponds to.
*
* @var string
*/
public static string $model = Tag::class;
/**
* Get the resource fields.
*
* @return array
*/
public function fields(): array
{
return [
ID::make(),
DateTime::make('createdAt')->sortable()->readOnly(),
+ Str::make('name'),
DateTime::make('updatedAt')->sortable()->readOnly(),
];
}
/**
* Get the resource filters.
*
* @return array
*/
public function filters(): array
{
return [
WhereIdIn::make($this),
];
}
/**
* Get the resource paginator.
*
* @return Paginator|null
*/
public function pagination(): ?Paginator
{
return PagePagination::make();
}
}
And finally, our UserSchema
also needs a name
field:
namespace App\JsonApi\V1\Users;
use App\Models\User;
use LaravelJsonApi\Eloquent\Contracts\Paginator;
use LaravelJsonApi\Eloquent\Fields\DateTime;
use LaravelJsonApi\Eloquent\Fields\ID;
+use LaravelJsonApi\Eloquent\Fields\Str;
use LaravelJsonApi\Eloquent\Filters\WhereIdIn;
use LaravelJsonApi\Eloquent\Pagination\PagePagination;
use LaravelJsonApi\Eloquent\Schema;
class UserSchema extends Schema
{
/**
* The model the schema corresponds to.
*
* @var string
*/
public static string $model = User::class;
/**
* Get the resource fields.
*
* @return array
*/
public function fields(): array
{
return [
ID::make(),
DateTime::make('createdAt')->sortable()->readOnly(),
+ Str::make('name'),
DateTime::make('updatedAt')->sortable()->readOnly(),
];
}
/**
* Get the resource filters.
*
* @return array
*/
public function filters(): array
{
return [
WhereIdIn::make($this),
];
}
/**
* Get the resource paginator.
*
* @return Paginator|null
*/
public function pagination(): ?Paginator
{
return PagePagination::make();
}
}
Now we've created these schemas, we need to add them to our API server. Open the app/JsonApi/V1/Server.php
class and modify the allSchemas()
method:
protected function allSchemas(): array
{
return [
+ Comments\CommentSchema::class,
Posts\PostSchema::class,
+ Tags\TagSchema::class,
+ Users\UserSchema::class,
];
}
Relationship Endpoints
Now that we've added the relationships and schemas of the related models, we expect to see the relationships when we request the posts
resource. Give that a go now with same request you used in the previous chapter:
GET http://localhost/api/v1/posts/1 HTTP/1.1
Accept: application/vnd.api+json
The response JSON now looks like this:
{
"jsonapi": {
"version": "1.0"
},
"links": {
"self": "http:\/\/localhost\/api\/v1\/posts\/1"
},
"data": {
"type": "posts",
"id": "1",
"attributes": {
"content": "In our first blog post, you will learn all about Laravel JSON:API...",
"createdAt": "2021-09-19T15:47:49.000000Z",
"publishedAt": "2021-09-19T15:47:49.000000Z",
"slug": "welcome-to-laravel-jsonapi",
"title": "Welcome to Laravel JSON:API",
"updatedAt": "2021-09-19T15:47:49.000000Z"
},
"relationships": {
"author": {
"links": {
"related": "http:\/\/localhost\/api\/v1\/posts\/1\/author",
"self": "http:\/\/localhost\/api\/v1\/posts\/1\/relationships\/author"
}
},
"comments": {
"links": {
"related": "http:\/\/localhost\/api\/v1\/posts\/1\/comments",
"self": "http:\/\/localhost\/api\/v1\/posts\/1\/relationships\/comments"
}
},
"tags": {
"links": {
"related": "http:\/\/localhost\/api\/v1\/posts\/1\/tags",
"self": "http:\/\/localhost\/api\/v1\/posts\/1\/relationships\/tags"
}
}
},
"links": {
"self": "http:\/\/localhost\/api\/v1\/posts\/1"
}
}
}
As you can see, our posts
resource now has a relationships
member that contains the three relationships. At the moment, we don't see the content of each relationship - we see the URLs for the JSON:API defined relationship endpoints - one called related
and one called self
. These links provide information to the client so that it can programmatically follow these links to obtain the related resources.
Let's try the related
link for the author
relationship, using this request:
GET http://localhost/api/v1/posts/1/author HTTP/1.1
Accept: application/vnd.api+json
You should see this response:
HTTP/1.0 404 Not Found
Content-Type: application/vnd.api+json
{
"jsonapi": {
"version": "1.0"
},
"errors": [
{
"status": "404",
"title": "Not Found"
}
]
}
This tells us we need to add these routes to the Laravel router. Let's do that now.
Routing
Update your /routes/api.php
to add the following:
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use LaravelJsonApi\Laravel\Facades\JsonApiRoute;
use LaravelJsonApi\Laravel\Http\Controllers\JsonApiController;
use LaravelJsonApi\Laravel\Routing\ResourceRegistrar;
+use LaravelJsonApi\Laravel\Routing\Relationships;
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
return $request->user();
});
JsonApiRoute::server('v1')->prefix('v1')->resources(function (ResourceRegistrar $server) {
- $server->resource('posts', JsonApiController::class)->readOnly();
+ $server->resource('posts', JsonApiController::class)
+ ->readOnly()
+ ->relationships(function (Relationships $relations) {
+ $relations->hasOne('author')->readOnly();
+ $relations->hasMany('comments')->readOnly();
+ $relations->hasMany('tags')->readOnly();
+ });
});
The relationships()
method allows us to define relationship routes for the posts
resource. It is provided with the $relations
helper, and we use that to add our relationships. Notice we use either hasOne()
or hasMany()
depending on whether the relationship is a to-one or to-many relationship.
At the moment we've marked these as read-only. This adds the following routes:
GET /api/v1/posts/<ID>/<RELATION_NAME>
GET /api/v1/posts/<ID>/relationships/<RELATION_NAME>
The first one of those is the GET
request we previously tried. Let's try it again:
GET http://localhost/api/v1/posts/1/author HTTP/1.1
Accept: application/vnd.api+json
You should see this response:
HTTP/1.1 401 Unauthorized
Content-Type: application/vnd.api+json
{
"jsonapi": {
"version": "1.0"
},
"errors": [
{
"detail": "Unauthenticated.",
"status": "401",
"title": "Unauthorized"
}
]
}
Just as we saw in the previous chapter, we need to update our authorization logic to allow this request.
Authorization
Our authorization logic is contained on our app/Policies/PostPolicy.php
class. We need to add new methods to that, to authorize each relationship request. Do that by adding the following methods after the existing view()
method:
/**
* Determine whether the user can view the model.
*
* @param \App\Models\User|null $user
* @param \App\Models\Post $post
* @return \Illuminate\Auth\Access\Response|bool
*/
public function view(?User $user, Post $post)
{
if ($post->published_at) {
return true;
}
return $user && $user->is($post->author);
}
+/**
+ * Determine whether the user can view the post's author.
+ *
+ * @param \App\Models\User|null $user
+ * @param \App\Models\Post $post
+ * @return \Illuminate\Auth\Access\Response|bool
+ */
+public function viewAuthor(?User $user, Post $post)
+{
+ return $this->view($user, $post);
+}
+/**
+ * Determine whether the user can view the post's comments.
+ *
+ * @param \App\Models\User|null $user
+ * @param \App\Models\Post $post
+ * @return \Illuminate\Auth\Access\Response|bool
+ */
+public function viewComments(?User $user, Post $post)
+{
+ return $this->view($user, $post);
+}
+/**
+ * Determine whether the user can view the post's tags.
+ *
+ * @param \App\Models\User|null $user
+ * @param \App\Models\Post $post
+ * @return \Illuminate\Auth\Access\Response|bool
+ */
+public function viewTags(?User $user, Post $post)
+{
+ return $this->view($user, $post);
+}
These methods determine whether the guest or user can view the post's relationships. For example, the viewAuthor()
method determines whether they can view the post's author. That's why it is on the PostPolicy
- we are viewing a specific post's author, so we need to authorize the request for that specific post.
The above code you added calls the view()
method, because we want the logic to be identical to the logic for viewing a specific post.
The Related Link
Now we've updated the authorization logic, try the request again:
GET http://localhost/api/v1/posts/1/author HTTP/1.1
Accept: application/vnd.api+json
Success! We can now see that Artie Shaw is the author of the post:
HTTP/1.1 200 OK
Content-Type: application/vnd.api+json
{
"jsonapi": {
"version": "1.0"
},
"data": {
"type": "users",
"id": "1",
"attributes": {
"createdAt": "2021-09-19T15:47:48.000000Z",
"name": "Artie Shaw",
"updatedAt": "2021-09-19T15:47:48.000000Z"
},
"links": {
"self": "http:\/\/localhost\/api\/v1\/users\/1"
}
}
}
We can see that because the related
relationship endpoint shows us the entire related resource - i.e. we see the users
resource with all its attributes
.
TIP
Have a go at requesting the related
endpoints for the comments
and tags
relationships.
The Self Link
The JSON:API specification also defines a self
link for each relationship, which you could see in our posts
resource earlier in the chapter. Let's try that endpoint with this request:
GET http://localhost/api/v1/posts/1/relationships/author HTTP/1.1
Accept: application/vnd.api+json
This time you'll see the following response:
HTTP/1.1 200 OK
Content-Type: application/vnd.api+json
{
"jsonapi": {
"version": "1.0"
},
"links": {
"self": "http:\/\/localhost\/api\/v1\/posts\/1\/relationships\/author",
"related": "http:\/\/localhost\/api\/v1\/posts\/1\/author"
},
"data": {
"type": "users",
"id": "1"
}
}
Notice this time in the data
member of the response, we can only see the type
and id
of the related resource. This is because the self
relationship link returns just the resource identifier - i.e. the type
and id
that uniquely identify the related resource.
TIP
Have a go at requesting the self
endpoints for the comments
and tags
relationships.
Including Related Resources
While these relationships are useful, what happens if our API client wanted to retrieve a post and it's related resources? If we had to use the relationship endpoints, the client would end up needing to send multiple HTTP requests to assemble all the data it needed.
Luckily the JSON:API specification provides us with a way to do this - the include
query parameter.
Imagine the API client is a frontend Javascript application. If it was going to display a specific blog post, it would likely want to show the post and the name of the related author. It would also want to show all the comments that have been made about the post, plus the name of the user who wrote the comment.
To do this, we need to set the include
query parameter to: author,comments.user
. This says, we want to include the post's author, plus its comments and each user attached to each comment.
Try that in the following request:
GET http://localhost/api/v1/posts/1?include=author,comments.user HTTP/1.1
Accept: application/vnd.api+json
And you'll see the following response:
HTTP/1.1 400 Bad Request
Content-Type: application/vnd.api+json
{
"jsonapi": {
"version": "1.0"
},
"errors": [
{
"detail": "Include path comments.user is not allowed.",
"source": {
"parameter": "include"
},
"status": "400",
"title": "Invalid Query Parameter"
}
]
}
That error message tells us that we cannot include comments.user
. This is because by default Laravel JSON:API only allows include paths with a depth of 1 (e.g. our author
), whereas comments.user
has a depth of 2. To allow a depth of 2, we update our app/JsonApi/V1/Posts/PostSchema.php
class:
namespace App\JsonApi\V1\Posts;
use App\Models\Post;
use LaravelJsonApi\Eloquent\Contracts\Paginator;
use LaravelJsonApi\Eloquent\Fields\DateTime;
use LaravelJsonApi\Eloquent\Fields\ID;
use LaravelJsonApi\Eloquent\Fields\Relations\BelongsTo;
use LaravelJsonApi\Eloquent\Fields\Relations\BelongsToMany;
use LaravelJsonApi\Eloquent\Fields\Relations\HasMany;
use LaravelJsonApi\Eloquent\Fields\Str;
use LaravelJsonApi\Eloquent\Filters\WhereIdIn;
use LaravelJsonApi\Eloquent\Pagination\PagePagination;
use LaravelJsonApi\Eloquent\Schema;
class PostSchema extends Schema
{
/**
* The model the schema corresponds to.
*
* @var string
*/
public static string $model = Post::class;
+ /**
+ * The maximum include path depth.
+ *
+ * @var int
+ */
+ protected int $maxDepth = 3;
/**
* Get the resource fields.
*
* @return array
*/
public function fields(): array
{
return [
ID::make(),
BelongsTo::make('author')->type('users')->readOnly(),
HasMany::make('comments')->readOnly(),
Str::make('content'),
DateTime::make('createdAt')->sortable()->readOnly(),
DateTime::make('publishedAt')->sortable(),
Str::make('slug'),
BelongsToMany::make('tags'),
Str::make('title')->sortable(),
DateTime::make('updatedAt')->sortable()->readOnly(),
];
}
/**
* Get the resource filters.
*
* @return array
*/
public function filters(): array
{
return [
WhereIdIn::make($this),
];
}
/**
* Get the resource paginator.
*
* @return Paginator|null
*/
public function pagination(): ?Paginator
{
return PagePagination::make();
}
}
Retry the request, and you should now see the following response:
HTTP/1.1 200 OK
Content-Type: application/vnd.api+json
{
"jsonapi": {
"version": "1.0"
},
"links": {
"self": "http:\/\/localhost\/api\/v1\/posts\/1"
},
"data": {
"type": "posts",
"id": "1",
"attributes": {
"content": "In our first blog post, you will learn all about Laravel JSON:API...",
"createdAt": "2021-09-19T15:47:49.000000Z",
"publishedAt": "2021-09-19T15:47:49.000000Z",
"slug": "welcome-to-laravel-jsonapi",
"title": "Welcome to Laravel JSON:API",
"updatedAt": "2021-09-19T15:47:49.000000Z"
},
"relationships": {
"author": {
"links": {
"related": "http:\/\/localhost\/api\/v1\/posts\/1\/author",
"self": "http:\/\/localhost\/api\/v1\/posts\/1\/relationships\/author"
},
"data": {
"type": "users",
"id": "1"
}
},
"comments": {
"links": {
"related": "http:\/\/localhost\/api\/v1\/posts\/1\/comments",
"self": "http:\/\/localhost\/api\/v1\/posts\/1\/relationships\/comments"
},
"data": [
{
"type": "comments",
"id": "1"
}
]
},
"tags": {
"links": {
"related": "http:\/\/localhost\/api\/v1\/posts\/1\/tags",
"self": "http:\/\/localhost\/api\/v1\/posts\/1\/relationships\/tags"
}
}
},
"links": {
"self": "http:\/\/localhost\/api\/v1\/posts\/1"
}
},
"included": [
{
"type": "users",
"id": "1",
"attributes": {
"createdAt": "2021-09-19T15:47:48.000000Z",
"name": "Artie Shaw",
"updatedAt": "2021-09-19T15:47:48.000000Z"
},
"links": {
"self": "http:\/\/localhost\/api\/v1\/users\/1"
}
},
{
"type": "comments",
"id": "1",
"attributes": {
"content": "Wow! Great first blog article. Looking forward to more!",
"createdAt": "2021-09-19T15:47:49.000000Z",
"updatedAt": "2021-09-19T15:47:49.000000Z"
},
"relationships": {
"user": {
"links": {
"related": "http:\/\/localhost\/api\/v1\/comments\/1\/user",
"self": "http:\/\/localhost\/api\/v1\/comments\/1\/relationships\/user"
},
"data": {
"type": "users",
"id": "2"
}
}
},
"links": {
"self": "http:\/\/localhost\/api\/v1\/comments\/1"
}
},
{
"type": "users",
"id": "2",
"attributes": {
"createdAt": "2021-09-19T15:47:48.000000Z",
"name": "Benny Goodman",
"updatedAt": "2021-09-19T15:47:48.000000Z"
},
"links": {
"self": "http:\/\/localhost\/api\/v1\/users\/2"
}
}
]
}
In this response, our posts
resource is in the top-level data
member of the JSON. Notice that the author
and comments
relationships now have a data
member in addition to the links
member. The relationship data
contains a JSON:API resource identifier - i.e. the type
and id
that uniquely identifies the related author and comments.
The related resources that are referenced by those identifiers are found in the top-level included
member of the JSON document. It's an array, containing all the related resources referenced in the document. In JSON:API this is known as a Compound Document.
This means as well as displaying the post, our frontend would now be able to display the author's name. It could then also display the comment, and the name of the user who wrote that comment.
TIP
Want to see the post and all its tags? Try changing the include
query parameter to tags
.
In Summary
In this chapter, we added relationships to our posts
resource and defined the related resources by adding several new schemas. We then learnt what JSON:API relationship endpoints are, and also used the include
query parameter to request a compound document containing the post and its related resources.
In the next chapter we'll learn how to create a new posts
resource.