« back published by @mmartin_joo on January 3, 2022

Proper API design in Laravel

In this blog post we're gonna focus on the fundamentals. Building a nice, usable, maintainable API. We're gonna mainly use:

  • JSON API
  • Spatie QueryBuilder
  • Actions

Our application is a really basic forum where you can:

  • Create categories
  • Post a thread in a category
  • Add tags to a thread
  • Reply to a thread
  • Archive a thread

These are the main actions and APIs in this sample app, so let's get started.

What is JSON API?

JSON API is a specification that tells you how to structure your response data, how to implement filtering, sorting, relationship including in your request URLs. Here's the basic structure of a response:

{
  "data": {
    "id": 1,
    "type": "threads",
    "attributes": {
      "slug": "this-is-a-thread",
      "title": "This is a thread",
      "body": "This is the actual content of the thread",
      "counters": {
        "share": 88,
        "like": 367,
        "reply": 6
      }
    },
    "relationships": {
      "author": {
        "data": { "type": "users", "id": 1}
      },
      "tags": [
        {"data": { "type": "tags", "id": 2}},
        {"data": { "type": "tags", "id": 16}},
      ]
    },
    "included": [
      {
        "id": 1,
        "type": "users",
        "attributes": {
          "name": "Micheal Scott",
          "email": "[email protected]"
        }
      },
      {
        "id": 2,
        "type": "tags",
        "attributes": {
          "name": "sales"
        }
      },
      {
        "id": 16,
        "type": "tags",
        "attributes": {
          "name": "paper"
        }
      }
    ]
  }
}

Some of the key things:

  • Every resource has an ID and a type as a root level attribute
  • Every other attribute lives in the attributes object
  • The relationships key only contains some meta information about the relationships
  • The included key actually contains the loaded relationships like author or tags

There are a tons of other thing in the actual specification, but I want to focus on the basics. That's the important stuff, and it's enough for us.

JSON API gives you a standardized way to structure your resources. If you like it, you can use it as your default choice. If you don't like you can stick to your custom format of course. We will use a package that makes really simple to write resources.

The cooler part of JSON API is the standardization of URL query parameters. Can you count how many of your projects have filtering and sorting through query parameters? And can you count how many different ways you implemented this in the previous years? In this blog post I'll show you a very elegant and robust solution to this problem.

Filtering with JSON API

Let's see how JSON API specifies filtering:

GET /threads?filter[title]=laravel&filter[title]=php

Here we say: "I only want threads that contains Laraval and PHP in the title". You can specify OR relation:

GET /threads?filter[name]=laravel,php

Sorting with JSON API

The next topic is sorting. It's an important one, and it's very straightforward:

GET /threads?sort=-like_count

Sorting threads descending based on like count. What about ascending?

GET /threads?sort=like_count

We just remove the - symbol. You can specify multiple columns:

GET /threads?sort=like_count,share_count

Including relationships with JSON API

It's a very good practice to let the client decide when to load a relationship and when not to. Later we can leverage Laravel API Resources to accomplish this. With JSON API it's really simple:

GET /threads?include=tags

A thread can have a lot of tags, and maybe we don't want to include them every single time, so we let the client decide it. We can include multiple stuff:

GET /threads?include=tags,author

Spare fieldsets with JSON API

SELECT * queries can be slow and time-consuming, and often we don't need every single column in the response. We can solve it like this:

GET /threads?fields[threads]=id,title,body

We can also specify fields for included relationships:

GET /threads?include=author&fields[author]=id,name

And of course JSON API also specifies pagination, but that's already solved by Laravel out of the box, so we're gonna skip this part. The next question is: how do we implement all this complicated, generic stuff for every model? Luckily we don't have to. We already have the solution. Let's learn about Query Builders!

Laravel Query Builder classes

Consider the following snippet:

$builder = Thread::where('title', $title);

In this case is an instance of Illuminate\Database\Eloquent\Builder which the base query builder class of Laravel. You already know that. What you may not know is that there's a newEloquentBuilder() method on the base Eloquent Model class and we can override it, like this:

public function newEloquentBuilder($query)
{
    return new ThreadBuilder($query);
}

And we can create our own ThreadBuilder class that extends the Illuminate\Database\Eloquent\Builder class. If you use Laravel I guess you have already used scopes. But did know that scopes are just syntactic sugar around Builder? Let's see how we can implement a scope in the ThreadBuilder:

namespace App\Builders;

use Illuminate\Database\Eloquent\Builder;

class ThreadBuilder extends Builder
{
    public function wherePopular(): self
    {
        $this->where(function($query) {
            $query->where('like_count', '>', 500)
                ->orWhere('share_count', '>', 100)
                ->orWhere('reply_count', '>', 50);
        });

        return $this;
    }
}

Now we have a scope with the name of wherePopular, and we can use it like this:

Thread::wherePopular()->where('title', 'LIKE', '%laravel%')->get();

It will give you all threads that contains "laravel" in the title and also popular. Okay, so why this is good? That's a couple of reasons:

  • You can simplify your models. A model meant to be a representation of a database record. Not a class that contains business logic.
  • You have a place for all of your scopes.
  • It's a perfect place to implement our filtering, sorting logic.

Now, let's meet the Spatie QueryBuilder!

Spatie Query Builder

In my opinion this is the most important package when building great APIs. This package implements all of the:

  • Sorting
  • Filtering
  • Including relationships
  • Spare fieldsets
  • Paginating

These problems are solved out of the box using QueryBuilder classes. Let's see how it looks like!

Filtering with Spatie Query Builder

// GET /threads?filter[title]=laravel&filter[title]=php
$threads = QueryBuilder::for(Thread::class)
    ->allowedFilters(['title', 'body'])
    ->get();

We create a QueryBuilder for a specific model (in this case Thread), and define what filters we want to allow. Then it will get these filters from the request, and apply them by where filters. That's it! You're done. You have a fully functioning filter. You can also apply additional where statements or scopes:

$threads = QueryBuilder::for(Thread::class)
    ->allowedFilters(['title', 'body', 'author.name'])
    ->where('category_id', $category->id)
    ->wherePublic()
    ->get();

Sorting with Spatie Query Builder

// GET /threads?sort=reply_count
$threads = QueryBuilder::for(Thread::class)
    ->allowedSorts(['like_count', 'reply_count'])
    ->get();

We can specify what columns can be used in the ORDER BY clause. If the URL doesn't contain a sort query, we can apply a default sort:

// GET /threads
$threads = QueryBuilder::for(Thread::class)
    ->defaultSort('title')
    ->allowedSorts(['like_count', 'reply_count'])
    ->get();

In this case threads will be sorted by title because there's no sort specified in the URL.

Including relationships with Spatie Query Builder

// GET /threads?include=author
$threads = QueryBuilder::for(Thread::class)
    ->allowedIncludes(['author', 'tags'])
    ->get();

It's easy... And finally the spare fieldsets.

Spare fieldsets with Spatie Query Builder

// GET /threads?fields[threads]=id,title
$threads = QueryBuilder::for(User::class)
    ->allowedFields(['id', 'title'])
    ->get();

As you can see, it's a really great, and powerful package. WIth this knowledge let's start our sample API using these principles.

If you care about code quality and want to automate a good chunk of your code reviews, check out Laracheck Laracheck

API versioning

If you look inside the repository you can see that we have a file called v1.php in the routes folder. This file contains all of our API v1 routes. If we need to change our API, we can create a new v2.php and put the API v2 routes inside it. You can configure it in the RouteServiceProvider::boot() method:

public function boot()
{
    $this->configureRateLimiting();

    $this->routes(function () {
        Route::prefix('api/v1')
            ->middleware(['api', 'auth:sanctum'])
            ->group(base_path('routes/v1.php'));
    });
}

We can specify the middlewares as well, so in the v1.php we just list our routes:

use App\Http\Controllers\CategoryController;
use App\Http\Controllers\PopularThreadController;
use App\Http\Controllers\ReplyController;
use App\Http\Controllers\ThreadController;
use Illuminate\Support\Facades\Route;

Route::get('categories/{category}/threads/popular', [PopularThreadController::class, 'index']);

Route::apiResource('categories', CategoryController::class)
        ->only(['index', 'store', 'destroy']);

Route::apiResource('categories/{category}/threads', ThreadController::class)
        ->except('update');

Route::apiResource('categories/{category}/threads/{thread}/replies', ReplyController::class)
        ->only(['index', 'store', 'destroy']);

I like using nested resources and nested routes, something like:

GET /api/v1/categories/my-category/threads

This helps me to keep organized and also to write simple controllers that stick to the basic API resource methods:

  • index: GET /categories
  • show: GET /categories/my-category
  • store: POST /categories
  • update: PUT /categories/my-category
  • destory: DELETE /categories/my-category

Categories API

Category is a root level resource. It means that they can live on their own without a parent resource. Our API should reflect this:

GET categories
GET categories/web-development
POST categories/web-development
PUT categories/web-development
DELETE categories/web-development

After all the theory let's jump into the code. We start with the categories API since this is the most basic.

This is the index action:

public function index()
{
    $categories = QueryBuilder::for(Category::class)
        ->allowedFilters(['name'])
        ->whereHas('threads')
        ->get();

    return CategoryResource::collection($categories);
}

You can append any other builder method to the Spatie builder class, like the whereHas(). This method returns every category that has at least one thread, and allows to filter by name. The CategoryResource uses the json-api package by timacdonald:

use TiMacDonald\JsonApi\JsonApiResource;

class CategoryResource extends JsonApiResource
{
    public function toAttributes($request): array
    {
        return [
            'name' => $this->name,
            'slug' => $this->slug,
        ];
    }
}

With this package you don’t need to define:

  • ID
  • Type
  • Attributes

It will produce this JSON automatically. By default it uses the id column from your models. You can override this behavior.

The store and destroy methods in the CategoryController are really simple:

public function store(StoreCategoryRequest $request)
{
    $this->authorize('create', Category::class);

    $category = Category::create([
        'name' => $request->name,
    ]);

    return newCategoryResource($category);
}

public function destroy(Category $category)
{
    $this->authorize('delete', $category);

    $category->delete();
    return response('', Response::HTTP_NO_CONTENT);
}

One main point. Just because I normally use services or actions, I don't always apply them to really really basic stuff like these ones. You can call it inconsistency and you're probably right, but the end of the day this is a basic sample project.

Threads API

Since all threads live inside a category our API endpoints should reflect this:

GET categories/web-development/threads
GET categories/web-development/threads/proper-api-design-with-laravel
POST categories/web-development/threads
PUT categories/web-development/threads/proper-api-design-with-laravel
DELETE categories/web-development/threads/proper-api-design-with-laravel

Here's how we apply query builder in the index action:

public function index(Category $category)
{
    $threads = QueryBuilder::for(Thread::class)
        ->allowedFilters(['title', 'body', 'author.name'])
        ->allowedIncludes(['author', 'tags'])
        ->allowedSorts(['title', 'like_count'])
        ->where('category_id', $category->id)
        ->wherePublic()
        ->get();

    return ThreadResource::collection($threads);
}

Notice the author.name filter. This makes possible to filter our threads by their author's name, using the URL:

GET http://127.0.0.1:8000/api/v1/categories/web-development/threads?include=author&filter[author.name]=Martin Joo

Resulting the following JSON response:

{
  "data": [
    {
      "id": 1,
      "type": "threads",
      "attributes": {
        "slug": "doloribus-quia-blanditiis-porro-eaque",
        "title": "doloribus quia blanditiis porro eaque",
        "body": "Sed odit quas sint fugiat repudiandae et earum. Quod distinctio et ipsum et quas harum minima. Ea rerum aut beatae harum et consequatur. Exercitationem ut ex officiis illo officiis nihil. Qui cum voluptas quia sed vero in qui et.\n\nCupiditate itaque et pariatur consequatur quas qui blanditiis. Sequi sed ab eum voluptatibus error alias at odit. Sapiente expedita assumenda assumenda iure rerum laborum.\n\nInventore ipsa facilis illum dolores. Non est atque aut unde optio ullam ipsum. Qui enim et nam et nam. Non et et blanditiis nihil eum.",
        "counters": {
          "share": 88,
          "like": 367,
          "reply": 6
        }
      },
      "relationships": {
        "author": {
          "data": {
            "id": "1",
            "type": "users"
          }
        }
      },
      "meta": {},
      "links": {},
    }
  ],
  "included": [
    {
      "id": "1",
      "type": "users",
      "attributes": {
        "name": "Martin Joo",
        "email": "[email protected]"
      },
      "relationships": {},
      "meta": {},
      "links": {}
    }
  ]
}

The GET request above with author included yields this query:

select
  *
from
  `threads`
where
  exists (
    select
      *
    from
      `users`
    where
      `threads`.`author_id` = `users`.`id`
      and LOWER(`users`.`name`) LIKE "Martin Joo"
  )
  and `category_id` = 1
  and `is_public` = true
  and `threads`.`deleted_at` is null

As you can see the Spatie query builder uses LIKE by default and it also uses the LOWER to make our query case-insensitive. All of this for free! I use the SoftDeletes trait in the Thread model so this is why the query has a deleted_at is null clause.

In the index action I also use a wherePublic() function. This comes from the ThreadBuilder class:

class ThreadBuilder extends Builder
{
    public function wherePublic(): self
    {
        $this->where('is_public', true);
        return $this;
    }

    public function wherePrivate(): self
    {
        $this->where('is_public', false);
        return $this;
    }

    public function wherePopular(): self
    {
        $this->where(function($query) {
            $query->where('like_count', '>', 500)
                ->orWhere('share_count', '>', 100)
                ->orWhere('reply_count', '>', 50);
        });

        return $this;
    }
}

As I mentioned earlier QueryBuilder is the standard way to use scopes.

Now let's create a thread:

public function store(CreateThreadRequest $request, Category $category)
{
    $this->authorize('create', Thread::class);
    $thread = $this->createThread->execute($category, $request);

    return (new ThreadResource($thread))
        ->response()
        ->setStatusCode(Response::HTTP_CREATED);
}

The action itself is very straightforward. There are two two thing that worth mentioning, the CreateThreadRequest and the $this→createThread. Let's start with the request:

class CreateThreadRequest extends FormRequest
{
    public function getTitle(): string
    {
        return $this->title;
    }

    public function getBody(): string
    {
        return $this->body;
    }

    /**
     * @return Collection<string>
     */
    public function getTags(): Collection
    {
        $tags = $this->tags ?: [];
        return collect($tags);
    }

    public function rules()
    {
        return [
            'title' => 'required|string|min:10',
            'body' => 'required|string|min:20',
            'tags' => 'nullable|sometimes|array',
            'tags.*' => 'nullable|sometimes|string',
        ];
    }
}

I just love to add getters to requests. It's a win for three reasons:

  • You actually know what's on the request. This is a big one for me. No more guessing, the request just shows me what is available.
  • You can and should type hint them. It's 2021 and PHP8.1, use the freaking type hints!
  • You can do basic data transformation. I mean, very basic, like the getTags() transforming an array or null to a Collection.

In bigger, complicated projects I use DTOs, but in smaller projects I'm only using Requests with getters. In a moment you will see that we are passing this request to an action that actually creates the thread.

The last piece of the puzzle is the CreateThread action. Earlier a published a post about action classes so please read it if you don't know what I'm talking about.

class CreateThread
{
    public function execute(Category $category, CreateThreadRequest $request): Thread
    {
        /** @var Thread $thread */
        $thread = Thread::create([
            'title' => $request->getTitle(),
            'body' => $request->getBody(),
            'author_id' => $request->user()->id,
            'category_id' => $category->id,
            'like_count' => 0,
            'share_count' => 0,
            'reply_count' => 0,
        ]);

        $tags = Tag::createMissing($request->getTags());
        $thread->tags()->sync($tags->pluck('id'));

        return $thread;
    }
}

Very easy. Please notice that the Tag::createMissing() is not a method on the model. It comes from the TagBuilder class:

class TagBuilder extends Builder
{
    public function createMissing(Collection $tagNames): Collection
    {
        return $tagNames->map(fn (string $name) => Tag::firstOrCreate(['name' => $name]));
    }
}

It gets a collection of tag names, and queries them from the database or creates a new one if it does not exist. It's not the best solution from a performance point of view, but this post is not about performance. And you can always put this logic into a background job, so if it's slow at least it doesn't block the response.

Our last action is the destroy. I created a separate action class for this but it's really not necessary:

class ArchiveThread
{
    public function execute(Thread $thread):void
    {
        $thread->title = Str::of($thread->title)->prepend('[ARCHIVED] ');
        $thread->save();
        $thread->delete();
    }
}

It prepends the word [ARCHIVE] to the title saves it, and then deletes it. Threads use the SoftDeletes trait so it's not a real delete. Notice the Str::of(), it's the Laravel Fluent String API, and it's amazing.

The project has one more route which is the /threads/popular. It has a separate controller. This is the idea of nested resources. If you don't want to have endless controllers with custom actions on it (by custom I mean anything that's different from index, store, update, show, destroy), you can think of "popular threads" as it's own resource. It's a popular thread resource with a PopularThreadController. I think this is where your API become RESTful.

class PopularThreadController extends Controller
{
    public function index()
    {
        $threads = QueryBuilder::for(Thread::class)
            ->allowedSorts(['like_count', 'share_count', 'reply_count'])
            ->defaultSort('-like_count')
            ->wherePublic()
            ->wherePopular()
            ->get();

        return ThreadResource::collection($threads);
    }
}

We use the wherePublic() and the wherePopular() scopes as well. Both of them lives on the ThreadBuilder class.

In this project the ThreadResource is the most "complicated" so I want to show it to you:

use TiMacDonald\JsonApi\JsonApiResource;

class ThreadResource extends JsonApiResource
{
    public function toAttributes($request): array
    {
        return [
            'slug' => $this->slug,
            'title' => $this->title,
            'body' => $this->body,
            'counters' => [
                'share' => $this->share_count,
                'like' => $this->like_count,
                'reply' => $this->reply_count,
            ],
        ];
    }

    public function toRelationships($request): array
    {
        return [
            'author' => fn () => new AuthorResource($this->author),
            'category' => fn () => new CategoryResource($this->category),
            'tags' => fn () => TagResource::collection($this->tags),
        ];
    }
}

It's a nice resource with some nested property that makes it more JSONy. As you can see we have a toRelationships method from the json-api package. This will populate the relationships and the included keys as well. The important thing is that all array keys like author is a callback. These keys are lazily evaluated. It means that this package checks the included URL parameter in the request and if a relationship is included only then the callback will be executed. In this way the client is in perfect control about what relationships it needs, and only query the necessary ones.

Replies API

Since all threads live inside a category our API endpoints should reflect this:

GET categories/web-development/threads/proper-api-design-with-laravel/replies
POST categories/web-development/threads/proper-api-design-with-laravel/replies
DELETE categories/web-development/threads/proper-api-design-with-laravel/replies/1

First, let's see how we add a reply to a thread:

public function store(StoreReplyRequest $request, Category $category, Thread $thread)
{
    return new ReplyResource($this->addReply->execute($thread, $request));
}

Once again we have a separate action class that gets the StoreReplyRequest:

class AddReply
{
    public function execute(Thread $thread, StoreReplyRequest $request): Reply
    {
        /** @var Reply $reply */
        $reply = $thread->replies()->create([
            'body' => $request->getBody(),
            'user_id' => $request->user()->id,
        ]);

        $thread->author->notify(new NewReplyAddedNotification($reply));
        return $reply;
    }
}

Nice and simple. I added a notification as well. The notify() method will queue this by default, so we don't need to do extra work.

The other two action in the controller is really straightforward:

public function index(Category $category, Thread $thread)
{
    return ReplyResource::collection($thread->replies->load('author'));
}

public function destroy(Category $category, Thread $thread, Reply $reply)
{
    $this->authorize('delete', $reply);
    $reply->delete();

    return response('', Response::HTTP_NO_CONTENT);
}

Conclusion

JSON API is a good standard. In this post I only used part of it. Only the part I like about it. It has much more in the original spec, and I think it has some unnecessary complications in it. But at least it gives us a standard way to write our APIs. This is extremely important! Especially when you write public APIs that a lot of other developers use.

If you don't like the whole "attributes", and "included" stuff, at least consider using the standardized filter, sort, sparse fieldset, include behavior.

The other important tool is Spatie QueryBuilder. I mean, this is a fantastic package. So use it, and stop re-inventing the wheel and your custom filter, sort API.

If you want to learn more about API design, check out my eBook Test-Driven APIs with Laravel and Pest.