How to use Laravel JSON:API to create a JSON:API compliant backend in Laravel

Denisa Halmaghi
Graffino
Published in
14 min readNov 24, 2021

WHAT WE’LL BE DOING

In this article we’ll build a simple JSON:API compliant set of APIs which will allow us to create tasks and attach assignees to them. Additionally, we’ll also test a few endpoints to make sure they work properly.

In order to quickly prototype our APIs we’ll be using an amazing Laravel library called Laravel JSON:API.

WHY USE JSON:API?

  • Standardized, consistent APIs
  • Feature rich — some of which are: sparse fieldsets (only fetch the fields you need), filtering, sorting, pagination, eager loading for relationships (includes, which solve the N+1 Problem)
  • Easy to understand

WHY USE LARAVEL JSON:API?

  • It saves a lot of development time
  • Highly maintainable code
  • Great, extensive documentation
  • Strong conventions (in terms of naming), but highly customizable
  • Makes use of native Laravel features such as policies and form requests to make the shift easier for developers (we’ll see later what this is about)

PREREQUISITES

  • Composer installed on your machine
  • A working database connection
  • A running (preferably fresh) Laravel application

MAIN PACKAGES USED AND THEIR VERSIONS

  • Laravel 8.0
  • Laravel JSON:API 1.0
  • Laravel JSON:API Testing 1.0

INSTALLATION

We’ll start by installing the Laravel JSON:API core and testing packages:

Install the Laravel JSON:API core package

composer require laravel-json-api/laravel

Install the Laravel JSON:API testing package

composer require --dev laravel-json-api/testing

SETUP

Now that we have everything installed, let’s publish (instruct Laravel to get all the “publishable” assets from the vendor package we choose and place them in our project directory in their respective folders) the configuration file and start customizing:

Publish config

php artisan vendor:publish --provider="LaravelJsonApi\Laravel\ServiceProvider"

The published config file will look like this:

jsonapi.php

return [
/*
|------------------------------------------------------------------
| Root Namespace
|------------------------------------------------------------------
|
| The root JSON:API namespace, within your application's namespace.
| This is used when generating any class that does not sit *within*
| a server's namespace. For example, new servers and filters.
|
| By default this is set to `JsonApi` which means the root namespace
| will be `\App\JsonApi`, if your application's namespace is `App`.
*/
'namespace' => 'JsonApi',
/*
|------------------------------------------------------------------
| Servers
|------------------------------------------------------------------
|
| A list of the JSON:API compliant APIs in your application, referred to
| as "servers". They must be listed below, with the array key being the
| unique name for each server, and the value being the fully-qualified
| class name of the server class.
*/
'servers' => [
// 'v1' => \App\JsonApi\V1\Server::class,
],
];

Now we need to generate a server file and then uncomment the entry in the servers array:

Generate a Server file

php artisan jsonapi:server v1

jsonapi.php

'servers' => [
'v1' => \App\JsonApi\V1\Server::class,
],

We currently do not need to modify anything in the generated server file, but later on we will have to register our Schemas (which describe how the models are serialized when creating the response) in here.

app/JsonApi/V1/Server.php

class Server extends BaseServer
{
/**
* The base URI namespace for this server.
*
* @var string
*/
protected string $baseUri = '/api/v1';
/**
* Bootstrap the server when it is handling an HTTP request.
*
* @return void
*/
public function serving(): void
{
// no-op
}
/**
* Get the server's list of schemas.
*
* @return array
*/
protected function allSchemas(): array
{
return [
// @TODO
];
}
}

With our application configured, we can start generating and writing the necessary code in order to achieve our goal.

LARAVEL FILES

In this section we will generate the necessary task model and migrations. Also, since we will test our endpoints at the end, we will create an additional factory for the tasks. However, we won’t start filling out the factory until the testing phase.

In order to generate these files run the following command:

Generate the task model, migration and factory

php artisan make:model Task -mf

With this command we instruct artisan to create a model and the corresponding migration and factory.

MIGRATIONS

With these files generated, we can start defining our tasks, beginning with the migration:

The task migration

class CreateTasksTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('tasks', function (Blueprint $table) {
$table->id();
$table->string("title");
$table->unsignedBigInteger('creator_id');
$table->timestamps();
$table->foreign('creator_id')
->references('id')
->on('users');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('tasks');
}
}

Because we want to be able to assign multiple users to multiple tasks, we now need to generate another migration, but this time for the pivot table which will link users with their assigned tasks:

Generate the pivot table for tasks and users

php artisan make:migration create_task_user_table

Now we can add the following fields to ensure the mapping of the two resources as well as data integrity:

The task_user migration

class CreateTaskUserTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('task_user', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('user_id');
$table->unsignedBigInteger('task_id');
$table->timestamps();
$table->foreign('user_id')
->references('id')
->on('users');
$table->foreign('task_id')
->references('id')
->on('tasks');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('task_user');
}
}

MODEL

Seeing as the database structure is established, we can now easily define the relationships we need:

The task model

class Task extends Model
{
use HasFactory;
protected $fillable = ["title"]; public function creator()
{
return $this->belongsTo(User::class);
}
public function assignees()
{
return $this->belongsToMany(User::class);
}
}

JSON API FILES

With our migrations and models in place, we can start generating the Laravel JSON:API necessary files.

SCHEMAS

We can create the Schemas for the User and Task models with this command:

Generate the schemas for tasks and users

php artisan jsonapi:schema users --server=v1
php artisan jsonapi:schema tasks --server=v1

Now, we have to register these schemas inside our Server file to let Laravel JSON:API know about them:

Register the schemas in the server

protected function allSchemas(): array
{
return [
Users\UserSchema::class,
Tasks\TaskSchema::class,
];
}

Now that we’ve got that out of the way, let’s implement said schemas:

UserSchema.php

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\Relations\BelongsToMany;
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(),
Str::make("name"),
Str::make("email"),
Str::make('password')->hidden(),
BelongsToMany::make('tasks'),
DateTime::make('createdAt')->sortable()->readOnly(),
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();
}
}

Please note that even though we defined the password as an accepted field, we do not want to it to be visible when fetching user data.

TaskSchema.php

namespace App\JsonApi\V1\Tasks;use App\Models\Task;
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\Str;
use LaravelJsonApi\Eloquent\Filters\WhereIdIn;
use LaravelJsonApi\Eloquent\Pagination\PagePagination;
use LaravelJsonApi\Eloquent\Schema;
class TaskSchema extends Schema
{
/**
* The model the schema corresponds to.
*
* @var string
*/
public static string $model = Task::class;
/**
* Get the resource fields.
*
* @return array
*/
public function fields(): array
{
return [
ID::make(),
Str::make("title"),
BelongsTo::make("creator")->type('users')->readOnly(),
BelongsToMany::make('assignees')->type('users'),
DateTime::make('createdAt')->sortable()->readOnly(),
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();
}
}

One thing to note here is that for both the creator and assignees relationships, we want the resource type to be “users”, instead of the default — which would be the plural form of relationship’s name, in our case “creators” and “assignees” respectively.

We need to do this because otherwise Laravel JSON:API would look for the schemas of the aforementioned resources — which it would not find — and will in turn throw an error. And in addition to this, we are taking full advantage of schema and request/validator reusability.

One quick, but important observation is that even though at first glance writing the schemas might seem like a tedious task consisting mainly of duplication between the schemas, models and migrations, things aren’t exactly that they appear to be.

Schemas are in fact a powerful feature of Laravel Json:API because they allow us to decouple our app internal structure from the API layer. If we were to directly map our data model to our API we would run into quite a few problems such as:

  • Our API layer needing to constantly change to accommodate changes made to the internal structure
  • Security issues (we would be exposing the inner workings of our app to anyone willing to inspect the response)
  • Consumers would be exposed to implementation details which might lead to a lot of confusion

REQUESTS

After telling Laravel JSON:API how to interact with the underlying Eloquent models, we can specify how to validate the data received. To do so, we first need to generate the request classes:

Generate the request classes for tasks and users

php artisan jsonapi:request users --server=v1
php artisan jsonapi:request tasks --server=v1

Laravel JSON:API uses Laravel Form Requests to validate data, which is why the Request structure might seem familiar to you.

Thanks to this, we can easily use the readily available rules defined by Laravel itself, sprinkled with some Laravel JSON:API magic to validate relationships:

TaskRequest.php

class TaskRequest extends ResourceRequest
{
/**
* Get the validation rules for the resource.
*
* @return array
*/
public function rules(): array
{
return [
'title' => ['required','string','min:3'],
'assignees' => [JsonApiRule::toMany()],
];
}
}

One very important thing to remember is that every field we want to be passed forward and inserted into the database needs to be validated. Otherwise, despite its presence in the request it will be ignored since Laravel JSON:API only takes into consideration the validated data.

UserRequest.php

class UserRequest extends ResourceRequest
{
/**
* Get the validation rules for the resource.
*
* @return array
*/
public function rules(): array
{
return [
"name" => ['required','string','min:3'],
"email" => ['required','email'],
'password' => ['sometimes','string','min:8'],
];
}
}

CONTROLLERS AND ROUTES

All that remains to be done until we can test our application is to setup the controllers and register the routes for our resources:

The following commands will generate the controllers:

Generate the controllers

php artisan jsonapi:controller Api/V1/UserController
php artisan jsonapi:controller Api/V1/TaskController

Let’s glance at the code generated for us inside the Task Controller:

TaskController.php

class TaskController extends Controller
{
use Actions\FetchMany;
use Actions\FetchOne;
use Actions\Store;
use Actions\Update;
use Actions\Destroy;
use Actions\FetchRelated;
use Actions\FetchRelationship;
use Actions\UpdateRelationship;
use Actions\AttachRelationship;
use Actions\DetachRelationship;
}

It looks like Laravel JSON:API took care to add all the traits necessary for us to do just about any action within the REST API realm so we currently do not need to add anything to the file.

We can surreptitiously take a peek inside one of the actions — FetchOne for instance:

FetchOne.php

trait FetchOne
{
/**
* Fetch zero to one JSON API resource by id.
*
* @param Route $route
* @param StoreContract $store
* @return Responsable|Response
*/
public function show(Route $route, StoreContract $store)
{
$request = ResourceQuery::queryOne(
$resourceType = $route->resourceType()
);
$response = null; if (method_exists($this, 'reading')) {
$response = $this->reading($request);
}
if ($response) {
return $response;
}
$model = $store
->queryOne($resourceType, $route->modelOrResourceId())
->withRequest($request)
->first();
if (method_exists($this, 'read')) {
$response = $this->read($model, $request);
}
return $response ?: DataResponse::make($model)
->withQueryParameters($request);
}
}

All this trait does is ensure that a show method is present on the controller. The other actions have a similar structure — traits that expose a single method. In short, what Laravel Json:API does here is composing the controller through actions/traits.

Now, after our little exploration, all we’ve got left to do is register the routes. We can do that in `api.php`:

api.php

JsonApiRoute::server('v1')
->prefix('v1')
->namespace('App\Http\Controllers\Api\V1')
->resources(function ($server) {
$server->resource('users')
->parameter('id')
->relationships(function ($relationships) {
$relationships->hasMany('tasks');
});
$server->resource('tasks')
->parameter('id')
->relationships(function ($relationships) {
$relationships->hasMany('assignees');
$relationships->hasOne('creator');
});
});

We can check what routes were registered by using the following command:

List all the routes for the registered Laravel JSON:API resources

php artisan route:list --path=v1 --columns=URI,Method

And we’ll get the following results:

Laravel JSON:API routes

+----------+-------------------------------------------+| Method   | URI                                       |+----------+-------------------------------------------+| GET|HEAD | api/v1/tasks                              || POST     | api/v1/tasks                              || GET|HEAD | api/v1/tasks/{id}                         || DELETE   | api/v1/tasks/{id}                         || PATCH    | api/v1/tasks/{id}                         || GET|HEAD | api/v1/tasks/{id}/assignees               || GET|HEAD | api/v1/tasks/{id}/creator                 || DELETE   | api/v1/tasks/{id}/relationships/assignees || POST     | api/v1/tasks/{id}/relationships/assignees || PATCH    | api/v1/tasks/{id}/relationships/assignees || GET|HEAD | api/v1/tasks/{id}/relationships/assignees || GET|HEAD | api/v1/tasks/{id}/relationships/creator   || PATCH    | api/v1/tasks/{id}/relationships/creator   || POST     | api/v1/users                              || GET|HEAD | api/v1/users                              || DELETE   | api/v1/users/{id}                         || PATCH    | api/v1/users/{id}                         || GET|HEAD | api/v1/users/{id}                         || DELETE   | api/v1/users/{id}/relationships/tasks     || POST     | api/v1/users/{id}/relationships/tasks     || PATCH    | api/v1/users/{id}/relationships/tasks     || GET|HEAD | api/v1/users/{id}/relationships/tasks     || GET|HEAD | api/v1/users/{id}/tasks                   |+----------+-------------------------------------------+

Notice how all the routes were prefixed with `api/v1`? That’s because Laravel adds an `/api` prefix to the api file by default and we added a `v1` prefix to all the routes that fall under the v1 Server.

TESTS

We’ve finally registered all the necessary resources! We just need to fill in the TaskFactory and we’re ready to get on with testing:

The Task Factory

class TaskFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var string
*/
protected $model = Task::class;
/**
* Define the model's default state.
*
* @return array
*/
public function definition()
{
return [
"title" => $this->faker->sentence(2)
];
}
}

Well, now we should have everything we need in order to test whether what we have built so far actually works the way we intended. So, without further ado, let’s write our first test!

The test file can be generated using the following artisan command:

Generate the test file

php artisan make:test TasksTest

But before we start, we should add the following trait to our TestCase class so we can run our tests without any interference from previous ones:

TestCase.php

abstract class TestCase extends BaseTestCase
{
use CreatesApplication;
use RefreshDatabase;
}

First, we’ll test whether we can fetch all existing tasks from the database using this simple test:

TasksTest.php

class TasksTest extends TestCase
{
//trait coming from `laravel-json-api/testing`
use MakesJsonApiRequests;
//we'll need this for all our tests
protected $baseUrl = "api/v1/tasks";
/** @test */
public function itReadsAllTasks()
{
$models = Task::factory()->count(2)->create();
$response = $this->jsonApi()
->expects("tasks")
->get($this->baseUrl);
$response->assertStatus(200); $response->assertFetchedMany($models);
}
}

Let’s run the test and see what happens:

Run the test

php artisan test --filter=itReadsAllTasks

It appears that we get the following error:

First error

Illuminate\Database\QueryExceptionSQLSTATE[HY000]: General error: 1364 Field 'creator_id' doesn't have a default value (SQL: insert into `tasks` (`title`, `updated_at`, `created_at`) values (Quia inventore et., 2021-09-30 17:53:10, 2021-09-30 17:53:10))

Seems like we forgot all about the task creator! It should be automatically assigned when the task is being created so the following model event should do the job:

Adding a creating hook on the Task Model

protected static function booted()
{
parent::booted();
static::creating(function (Task $task) {
$task->creator()->associate(auth()->user());
});
}

After adding the aforementioned piece of code to the bottom of the Task model run the test again:

Second error

Illuminate\Database\QueryExceptionSQLSTATE[23000]: Integrity constraint violation: 1048 Column 'creator_id' cannot be null (SQL: insert into `tasks` (`title`, `creator_id`, `updated_at`, `created_at`) values (Sed modi pariatur., ?, 2021-09-30 18:00:57, 2021-09-30 18:00:57))

Apparently the creator still isn’t correctly attached to the task, but that’s because we forgot to login and hence, the authenticated user will always be null. We can easily remedy this by whipping up a user and then faking a login:

Updated TasksTest.php

class TasksTest extends TestCase
{
//trait coming from `laravel-json-api/testing`
use MakesJsonApiRequests;
//we'll need this for all our tests
protected $baseUrl = "api/v1/tasks";
protected function setUp(): void
{
parent::setUp();
//login with a randomly created user
$this->actingAs(User::factory()->create());
}
/** @test */
public function itReadsAllTasks()
{
$models = Task::factory()->count(2)->create();
$response = $this->jsonApi()
->expects("tasks")
->get($this->baseUrl);
$response->assertStatus(200); $response->assertFetchedMany($models);
}
}

After adding the setup method and running the test again we get the following results:

Authorization Exception

Illuminate\Auth\Access\AuthorizationExceptionThis action is unauthorized.

Oh snap! We need to find a way to authorize the API actions. Luckily, Laravel JSON:API allows us to make use of Laravel Policies so all we need to do is run the following command to generate our policy and then implement the authorization according to our requirements:

Generate Policy

php artisan make:policy TaskPolicy -m Task

For the purpose of this tutorial, we’ll allow any user to access our APIs. We can do so by adding a `return true` statement inside each policy method:

TaskPolicy.php

class TaskPolicy
{
use HandlesAuthorization;
/**
* Determine whether the user can view any models.
*
* @param \App\Models\User $user
* @return \Illuminate\Auth\Access\Response|bool
*/
public function viewAny(User $user)
{
return true;
}
/**
* Determine whether the user can view the model.
*
* @param \App\Models\User $user
* @param \App\Models\Task $task
* @return \Illuminate\Auth\Access\Response|bool
*/
public function view(User $user, Task $task)
{
return true;
}
/**
* Determine whether the user can create models.
*
* @param \App\Models\User $user
* @return \Illuminate\Auth\Access\Response|bool
*/
public function create(User $user)
{
return true;
}
/**
* Determine whether the user can update the model.
*
* @param \App\Models\User $user
* @param \App\Models\Task $task
* @return \Illuminate\Auth\Access\Response|bool
*/
public function update(User $user, Task $task)
{
return true;
}
/**
* Determine whether the user can delete the model.
*
* @param \App\Models\User $user
* @param \App\Models\Task $task
* @return \Illuminate\Auth\Access\Response|bool
*/
public function delete(User $user, Task $task)
{
return true;
}
}

Now, if we give our test another run it should pass:

Generate Policy

PASS	Tests\Feature\TasksTest
✓ it reads all tasks
Tests: 1 passed
Time: 3.12s

Now that we are certain that we can fetch all the tasks, let’s see whether we can properly create a task. This task will be created directly with a user assigned to it:

The task creation test

public function itCreatesATask()
{
$assignee = User::factory()->create();
$data = [
"type" => "tasks",
"attributes" => [
"title" => "My first task",
],
"relationships" => [
"assignees" => [
"data" => [
[
"type" => "users",
"id" => (string) $assignee->getKey()
]
]
]
]
];
$response = $this
->jsonApi()
->withData($data)
->post($this->baseUrl);
$response->assertStatus(201);
}

Let’s run the test and see if it passes:

Run the task creation test

php artisan test --filter=itCreatesATask

Test results

PASS	Tests\Feature\TasksTest
✓ it creates a task
Tests: 1 passed
Time: 3.01s

Great! It looks like we solved all the issues after making sure that the first test passes.

We’ll write one final test before wrapping up. This time we’ll test if we can include the task assignees in the payload when fetching a specific task:

Fetch the assignees along with the task

/** @test */
public function itCanIncludeATasksAssignees()
{
$task = Task::factory()
->hasAssignees(2)
->create();
$response = $this
->jsonApi("tasks")
->includePaths('assignees')
->get("{$this->baseUrl}/{$task->getKey()}");
$response->assertStatus(200); $included = [
[
"type" => "users",
"id" => (string) $task->assignees->first()->getKey()
],
[
"type" => "users",
"id" => (string) $task->assignees->last()->getKey()
],
];
$response->assertIncluded($included);
}

After running the test we get the following:

Test results

PASS	Tests\Feature\TasksTest
✓ it can include a tasks assignees
Tests: 1 passed
Time: 2.98s

To make sure we understand what include does, let’s hit this enpoint and analyze the response:

The request response

{
"jsonapi":{
"version":"1.0"
},
"links":{
"self":"http://localhost/api/v1/tasks/1"
},
"data":{
"type":"tasks",
"id":"1",
"attributes":{
"title":"Ipsam id.",
"createdAt":"2021-10-03T20:05:29.000000Z",
"updatedAt":"2021-10-03T20:05:29.000000Z"
},
"relationships":{
"creator":{
"links":{
"related":"http://localhost/api/v1/tasks/1/creator",
"self":"http://localhost/api/v1/tasks/1/relationships/creator"
}
},
"assignees":{
"links":{
"related":"http://localhost/api/v1/tasks/1/assignees",
"self":"http://localhost/api/v1/tasks/1/relationships/assignees"
},
"data":[
{
"type":"users",
"id":"2"
},
{
"type":"users",
"id":"3"
}
]
}
},
"links":{
"self":"http://localhost/api/v1/tasks/1"
}
},
"included":[
{
"type":"users",
"id":"2",
"attributes":{
"name":"Dr. Hazel Bahringer",
"email":"richard.abernathy@example.net",
"createdAt":"2021-10-03T20:05:29.000000Z",
"updatedAt":"2021-10-03T20:05:29.000000Z"
},
"relationships":{
"tasks":{
"links":{
"related":"http://localhost/api/v1/users/2/tasks",
"self":"http://localhost/api/v1/users/2/relationships/tasks"
}
}
},
"links":{
"self":"http://localhost/api/v1/users/2"
}
},
{
"type":"users",
"id":"3",
"attributes":{
"name":"Dakota Kassulke",
"email":"altenwerth.jordyn@example.org",
"createdAt":"2021-10-03T20:05:29.000000Z",
"updatedAt":"2021-10-03T20:05:29.000000Z"
},
"relationships":{
"tasks":{
"links":{
"related":"http://localhost/api/v1/users/3/tasks",
"self":"http://localhost/api/v1/users/3/relationships/tasks"
}
}
},
"links":{
"self":"http://localhost/api/v1/users/3"
}
}
]
}

The key takeaway here is that inside “included” we have the included relationships’ data — in our case the assignees relationship — and inside the task’s relationships entry we have the resources’ identifiers (composed of the resource’s type and id)

We’ll stop here, but if you want to test the rest of the actions take a look at this chapter in the documentation.

--

--