Building a REST API in Laravel, Part 2

Welcome to Part 2 of our series on building a REST API in Laravel. We’ll build our next endpoint ‘categories’ using Spatie’s query builder package. This package provides a simple and elegant way to filter, sort and include eloquent relations based on a request. It’s a powerful tool for creating efficient and performant APIs.

First step will be to use composer to include a couple of great Spatie packages:

  1. spatie/laravel-query-builder
  2. spatie/laravel-json-api-paginate

The query builder allows you to “Easily build Eloquent queries from API requests” and the paginator package “plays nice with the JSON API spec”.

The category controller will use the __invoke method to work with Spatie’s query builder:

app/Http/Controllers/API/CategoryController.php

namespace App\Http\Controllers\API;

use App\Http\Controllers\Controller;
use App\Http\Resources\CategoryResource;
use App\Models\Category;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Spatie\QueryBuilder\AllowedFilter;
use Spatie\QueryBuilder\QueryBuilder;

class CategoryController extends Controller
{
    /**
     * Handle the incoming request.
     *
     * @param \Illuminate\Http\Request $request
     * @return \Illuminate\Http\Resources\Json\JsonResource
     */
    public function __invoke(Request $request): JsonResource
    {
        $categories = QueryBuilder::for(Category::class)
            ->where('user_id', $request->user()->id)
            ->allowedIncludes(['products'])
            ->allowedFilters('name', AllowedFilter::exact('id'))
            ->jsonPaginate()
            ->appends(request()->query());

        return CategoryResource::collection($categories);
    }
}

The category resource will include the ‘whenLoaded’ method in case the products are requested via the endpoint:

app/Http/Resources/CategoryResource.php

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\JsonResource;

class CategoryResource extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
     */
    public function toArray($request)
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'products' => ProductResource::collection($this->whenLoaded('products')),
            'created_at' => $this->created_at,
            'updated_at' => $this->updated_at,
        ];
    }
}

For now we’ll work with a couple new routes:

routes/api.php

Route::post('category', CategoryController::class);
Route::get('category', CategoryController::class);

The Factory:

namespace Database\Factories;

use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Category>
 */
class CategoryFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition()
    {
        return [
            "name" => $this->faker->words(2, true),
            "description" => $this->faker->sentences(3, true),
        ];
    }
}

The Test:

namespace Tests\Feature;

use App\Models\Category;
use App\Models\Product;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ApiCategoryEndpointTest extends TestCase
{
    use RefreshDatabase;

    /**
     * Setup the test environment
     * @return void
     */
    public function setUp(): void
    {
        parent::setUp();

        // The acting user
        $this->user = User::factory()->create();

        // Test products and categories
        $products = Product::factory(2)->for($this->user)->create();
        $categories = Category::factory(2)->for($this->user)->create();

        // Sync products to categories
        $product_ids = $products->pluck('id')->all();

        foreach ($categories as $category)
            $category->products()->sync($product_ids);
    }

    /**
     * Tests that a user can get categories via product api endpoint
     * @return void
     */
    public function test_user_can_get_categories()
    {
        $this->actingAs($this->user);

        $response = $this->getJson('/api/category');

        $response->assertStatus(200)
            ->assertJsonCount(2, 'data')
            ->assertJsonStructure([
                'data',
                'links' => [
                    'first',
                    'last',
                    'prev',
                    'next',
                ],
                'meta',
            ]);
    }

    /**
     * Tests that a user can get a category using filter[id] via product api endpoint
     * @return void
     */
    public function test_user_can_get_a_category()
    {
        $this->actingAs($this->user);

        $response = $this->getJson('/api/category?filter[id]=1');

        $response->assertStatus(200)
            ->assertJsonCount(1, 'data')
            ->assertJsonStructure([
                'data'=> [['id', 'name']],
                'links' => [
                    'first',
                    'last',
                    'prev',
                    'next',
                ],
                'meta',
            ]);
    }

    /**
     * Tests that a user can get categories with products via category api endpoint
     * @return void
     */
    public function test_user_can_get_a_category_with_products()
    {
        $payload = ['include' => 'products'];

        $this->actingAs($this->user);

        $response = $this->getJson('/api/category?include=products', $payload);

        $response->assertStatus(200)
            ->assertJsonCount(2, 'data')
            ->assertJsonStructure([
                'data' => [['id', 'name', 'products']],
                'links' => [
                    'first',
                    'last',
                    'prev',
                    'next',
                ],
                'meta',
            ]);
    }
}

The migration:

use App\Models\Category;
use App\Models\Product;
use App\Models\User;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('categories', function (Blueprint $table) {
            $table->id();
            $table->foreignIdFor(User::class)->constrained();
            $table->string('name');
            $table->text("description");
            $table->timestamps();
        });

        // Create the product/category pivot table
        Schema::create('product_category', function (Blueprint $table) {
            $table->foreignIdFor(Product::class)->constrained();
            $table->foreignIdFor(Category::class)->constrained();
            $table->primary(['product_id', 'category_id']);
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('product_category');
        Schema::dropIfExists('categories');
    }
};

Overall, Spatie’s query builder package is an excellent choice for building REST APIs in Laravel and we highly recommend it for any developer looking to improve the efficiency and performance of their code. We hope that this post has provided you with a good understanding of how to use this package in your own projects and we look forward to hearing your feedback.

Please note: There are a few updates to the product endpoint from part 1 of this tutorial. Please refer to the github repo to check out the changes.

Related Posts