Building a REST API in Laravel, Part 1

Part 1: Laravel Setup and the First Endpoint

Welcome to Part 1 of our PHP coding tutorial on creating a REST API in Laravel!

In this series of articles, we’ll be discussing how to build a REST API using Laravel, a popular PHP MVC framework that simplifies the process of developing web applications.

REST APIs enable different systems and applications to communicate with one another by providing a standardized way to access and manipulate data.

In Part 1, we’ll be covering the following topics:

  • Setting up a new Laravel project
  • Defining routes for our API
  • Creating models to represent our data
  • Writing tests to ensure the reliability of our API
  • Creating controllers to handle requests and responses
  • Building our first endpoint for e-commerce products

By the end of this tutorial series, you’ll have a solid foundation for building your own REST APIs in Laravel. Let’s get started!

Install Laravel & Jetstream

Code available on https://github.com/MatrixPro/laravel-REST-API

Run the following commands to install Laravel and Jetstream (your preference, inertia or livewire). Don’t forget to setup a DB and update your .env:

composer create-project laravel/laravel laravel-rest-api
cd laravel-rest-api
composer require laravel/jetstream
php artisan jetstream:install inertia
npm install
npm run build
php artisan migrate

Open a couple terminals and run vite as well as artisan serve (or configure your environment of choice):

npm run dev
php artisan serve

At this point you should be up and running and be able to visit your localhost url. Registration, profile views, etc. should all be working.

Building the first REST API Endpoint

Enable API support in config/jetstream.php:

"features" => [
   Features::api(),
],

Our REST API will be centered around an e-commerce shop/CMS that, so far, has some faker products to play around with.

Model:

The product model extends eloquent, has a static $permissions attribute for easy access and a price accessor/mutator to convert to and from cents (how we store it in the DB) and decimal format (for display).

Generate the product model, migration and factory:

php artisan make:model Product -fm

/app/Models/Product.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Product extends Model
{
    use HasFactory;

    /*
     * Token permissions
     */
    public static $permissions = [
        "product:create",
        "product:read",
        "product:update",
        "product:delete",
    ];

    public function user()
    {
        return $this->belongsTo(User::class);
    }

    /**
     * Interact with the product's price.
     *
     * @return \Illuminate\Database\Eloquent\Casts\Attribute
     */
    protected function price(): Attribute
    {
        return Attribute::make(
            get: fn($value) => $value / 100,
            set: fn($value) => $value * 100,
        );
    }
}

Modify the product migration.

Product Migration:

<?php

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("products", function (Blueprint $table) {
            $table->id();
            $table->foreignId("user_id");
            $table->string("name");
            $table->string("sku");
            $table->text("description");
            $table->integer("price");
            $table->timestamps();
        });
    }

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

Run the migration:

php artisan migrate

Factory:

Typical factory. Uses faker for the attribute values. Keep in mind that, in our case, a product belongs to a user… when using the factory, a user object will have to be passed in the ‘for()’ method (ie  Product::factory(100)->for($this->user)->create()).

database/factories/ProductFactory.php

<?php

namespace Database\Factories;

use Illuminate\Database\Eloquent\Factories\Factory;

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

API Route:

The auth:sanctum middleware will be used for authorizing API endpoints/routes. Route::apiResource is used to save some time typing out the various routes. If you’re curious about what routes apiResource generates, you can use the artisan command ‘route:list’.

routes/api.php

<?php

use App\Http\Controllers\API\ProductController;
use App\Http\Controllers\API\TokenController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
*/

Route::post('/token', [TokenController::class, 'create']);

Route::middleware('auth:sanctum')->group(function () {
    Route::apiResource('product', ProductController::class);
});

API Resource:

Laravel’s API Resources allow us to modify the data returned to the user. At the moment, we’re simply using it to ensure we include only the attributes we want in the response.

Create product resource:

php artisan make:resource ProductResource

app/Http/Resources/ProductResource.php

<?php

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\JsonResource;

class ProductResource 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,
            "sku" => $this->sku,
            "description" => $this->description,
            "price" => $this->price,
        ];
    }
}

Policies:

Laravel’s policies allow us to define the logic that authorizes a user’s various actions, like read/write/etc.

Create the product policy:

php artisan make:policy ProductPolicy

app/Policies/ProductPolicy.php

<?php

namespace App\Policies;

use App\Models\Product;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
use Illuminate\Http\Request;

class ProductPolicy
{
    use HandlesAuthorization;

    /**
     * Determine whether the user can view any models.
     *
     * @param  \App\Models\Product  $product
     * @return \Illuminate\Auth\Access\Response|bool
     */
    public function read(User $user, Request $request)
    {
        return $request->user()->tokenCan('product:read');
    }

}

Update the jetstream service provider to use the new policy.

app/Providers/JetstreamServiceProvider.php

<?php

namespace App\Providers;

use App\Actions\Jetstream\DeleteUser;
use App\Models\Product;
use Illuminate\Support\ServiceProvider;
use Laravel\Jetstream\Jetstream;

class JetstreamServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     *
     * @return void
     */
    public function register()
    {
        //
    }

    /**
     * Bootstrap any application services.
     *
     * @return void
     */
    public function boot()
    {
        $this->configurePermissions();

        Jetstream::deleteUsersUsing(DeleteUser::class);
    }

    /**
     * Configure the permissions that are available within the application.
     *
     * @return void
     */
    protected function configurePermissions()
    {
        Jetstream::defaultApiTokenPermissions(['product:read']);

        Jetstream::permissions(Product::$permissions);
    }
}

Query Builders:

We’re using ‘query’ classes to define how our various queries will be built. We accomplish a couple things by doing this:

  1. Defines a single location/definition for a query, accessible via API or normal methods
  2. We can easily bind the query class interface to it’s implementation , keeping our code clean.

Now, for example, when we want to change how our products are retrieved we only need to swap out the ‘FetchProductsForUser’ class with another implementation.

app/Providers/AppServiceProvider.php

public function register()
{
    $this->app->bind(FetchProductsForUserInterface::class, FetchProductsForUser::class);
}

app/Contracts/FetchProductsForUserContract.php

<?php

namespace App\Contracts;

use Illuminate\Database\Eloquent\Builder;

interface FetchProductsForUserContract
{
    public function handle(Builder $builder, int $user_id): Builder;
}

app/Models/Queries/FetchProductsForUser.php

<?php

namespace App\Models\Queries;

use App\Contracts\FetchProductsForUserContract;
use Illuminate\Database\Eloquent\Builder;

class FetchProductsForUser implements FetchProductsForUserContract {

    public function handle(Builder $builder, int $user_id): Builder
    {
        return $builder->where('user_id', $user_id);
    }
}

Controllers:

About the API product controller:

  • Since each method will access the query builder, we’ll inject the dependency via the constructor and assign it to $this->builder.
  • We take advantage PHP 8’s nifty new feature called ‘named parameters’.
  • Auth permissions are checked via the Product Policy
  • The index method will be responsible for getting a cursor paginated list of products. Return is handled via a Resource Collection that ensures we include only the fields we want in the response.
  • The FetchProductsForUser class is injected into the index. It takes the query builder and returns it built out for getting the list of products.

About the API token controller:

While the user can log into their dashboard and generate a token, there are some cases where that option is unavailable or not ideal. An authorized user may generate a new token by posting their email, password and device name to the /token endpoint.

Create the product and token API controllers:

PHP artisan make:controller API/ProductController
PHP artisan make:controller API/TokenController

app/Http/Controllers/API/ProductController.php

<?php

namespace App\Http\Controllers\API;

use App\Http\Controllers\Controller;
use App\Http\Resources\ProductResource;
use App\Models\Product;
use App\Contracts\FetchProductsForUserInterface;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class ProductController extends Controller
{
    public function __construct(Product $product)
    {
        $this->builder = $product->query();
    }

    /**
     * Get paginated set of products
     * @param Request              $request The request
     * @param FetchProductsForUser $query Query builder
     * @return JsonResource
     * @throws \Illuminate\Auth\Access\AuthorizationException
     */
    public function index(Request $request, FetchProductsForUserInterface $query): JsonResource
    {
        $this->authorize("read", [Product::class, $request]);

        $products = $query->handle(
            builder: $this->builder,
            user_id: $request->user()->id
        )->cursorPaginate(
            perPage: 10,
            cursor: request("cursor")
        );

        return ProductResource::collection($products);
    }
}

app/Http/Controllers/API/TokenController.php

<?php

namespace App\Http\Controllers\API;

use App\Http\Controllers\Controller;
use App\Models\Product;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;

class TokenController extends Controller
{
    public function create(Request $request)
    {
        $request->validate([
            'email' => 'required|email',
            'password' => 'required',
            'device_name' => 'required',
        ]);

        $user = User::where('email', $request->email)->first();

        if (!$user || !Hash::check($request->password, $user->password)) {
            throw ValidationException::withMessages([
                'email' => ['The provided credentials are incorrect.'],
            ]);
        }

        $token = $user->createToken($request->device_name, Product::$permissions)->plainTextToken;

        $res = [
            'success' => true,
            'data'    => [
                'token' => $token
            ],
            'message' => 'Token successfully created!',
        ];

        return response()->json($res);
    }
}

Feature Tests:

Laravel’s Jetstream comes with a great set of tests. I would recommend going through each one and making sure it passes green for all the changes we’ve made. As far as our own, we’ve created some that tests various product API endpoint interactions. They are worth a look as they demonstrate how our API works so far.

tests/Feature/ApiProductEndpointTest.php

<?php

namespace Tests\Feature;

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

class ApiProductEndpointTest extends TestCase
{
    use RefreshDatabase;

    /**
     * Create a user and seed some products for the tests
     * @return void
     */
    public function setUp(): void
    {
        parent::setUp();

        $this->user = User::factory()->create();
        Product::factory(100)->for($this->user)->create();
    }

    /**
     * Tests that a token can be created with permissions
     * @return void
     */
    public function test_token_product_permissions()
    {
        $this->actingAs($this->user);

        $perms = [
            'product:create',
            'product:read',
            'product:update',
            'product:delete',
        ];

        /*
         * Confirm that test perms are same as actual perms
         */

        $this->assertSame($perms, Product::$permissions);

        /*
         * Create the token and check permissions
         */

        $token = $this->user->tokens()->create([
            'name' => 'Test Token',
            'token' => Str::random(40),
            'abilities' => $perms,
        ]);

        foreach (Product::$permissions as $permission)
            $this->assertTrue($token->can($permission));
    }

    /**
     * Tests that a user can get products via product api endpoint
     * @return void
     */
    public function test_user_can_get_products()
    {
        $payload = [];

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

        $response = $this->getJson('/api/product', $payload);

        $response->assertStatus(200)
            ->assertJsonCount(10, 'data')
            ->assertJsonStructure([
                'data',
                'links' => [
                    'next',
                ],
                'meta' => [
                    'path',
                    'per_page',
                    'next_cursor',
                    'prev_cursor',
                ],
            ]);
    }

    /**
     * Tests that a user can create and use tokens
     * @return void
     */
    public function test_user_can_create_and_use_token()
    {
        /*
         * Create the API token
         */

        $payload = ['email' => $this->user->email, 'password' => 'password', 'device_name' => 'iphone'];

        $response = $this->postJson('/api/token', $payload);

        $response->assertStatus(200)
            ->assertJsonStructure([
                'data' => [
                    'token',
                ],
                'success',
                'message',
            ]);

        $content = $response->decodeResponseJson();

        $token = $content['data']['token'];

        $response_b = $this->withHeaders(['Authorization' => 'Bearer ' . $token])
            ->getJson('/api/product');

        /*
         * Use token to get products, check response code, result count, and JSON structure
         */

        $response_b->assertStatus(200)
            ->assertJsonCount(10, 'data')
            ->assertJsonStructure([
                'data',
                'links' => [
                    'next',
                ],
                'meta' => [
                    'path',
                    'per_page',
                    'next_cursor',
                    'prev_cursor',
                ],
            ]);

        /*
         * Run same test again but use cursor
         */

        $content = $response_b->decodeResponseJson();
        $first_row = $content['data'][0];
        $cursor = $content['meta']['next_cursor'];

        $response_c = $this->withHeaders(['Authorization' => 'Bearer ' . $token])
            ->getJson('/api/product?cursor='.$cursor);

        $content = $response_c->decodeResponseJson();
        $second_row = $content['data'][0];

        /*
         * Test cursor pagination by checking that results are different
         */

        $this->assertNotSame($first_row, $second_row);

        /*
         * Check response code, result count, and JSON structure
         */

        $response_c->assertStatus(200)
            ->assertJsonCount(10, 'data')
            ->assertJsonStructure([
                'data',
                'links' => [
                    'next',
                ],
                'meta' => [
                    'path',
                    'per_page',
                    'next_cursor',
                    'prev_cursor',
                ],
            ]);
    }

}

Related Posts
Leave a Reply

Your email address will not be published.Required fields are marked *