loading...
Cover image for Build a Product Review REST API with Laravel

Build a Product Review REST API with Laravel

mr_steelze profile image Sholley O. ・11 min read

In this tutorial, we will be building a product reviews API. Users will be able to add, view, update and delete products. Users will also be able to rate and review a product. We will also be implementing authentication functionality with JSON Web Tokens (JWT) to secure our API.

Prerequisites

  • Basic knowledge of Laravel
  • Basic knowledge of REST APIs

Creating a new project

First we need to create a new laravel project

$ laravel new product-review-api

Then create your database and update the database credentials in the .envfile.

Create models and migrations

This API will be having 3 models: user, product and review. By default laravel comes with a user model, migration and factory file, so we will just be creating the remaining two. Let's start with the Product model:

$ php artisan make:model Product -a

The -a flag will create corresponding migration, controller, factory and seeder file for the model. We will be looking at the factory and seeder file in a bit.

Lets edit the product migration file as below:

Schema::create('products', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->longText('description');
    $table->decimal('price');
    $table->unsignedBigInteger('user_id');
    $table->timestamps();

    $table->foreign('user_id')
        ->references('id')
        ->on('users')
        ->onDelete('cascade');
});

We are just defining the columns for the products table and defining its relationship with the users table and what happens if the parent data is deleted.

Now we'll do the same for the Review model

$ php artisan make:model Review -a

And also define the reviews table structure

Schema::create('reviews', function (Blueprint $table) {
    $table->id();
    $table->unsignedBigInteger('user_id');
    $table->unsignedBigInteger('product_id');
    $table->text('review');
    $table->integer('rating');
    $table->timestamps();

    $table->foreign('user_id')
        ->references('id')
        ->on('users')
        ->onDelete('cascade');

    $table->foreign('product_id')
        ->references('id')
        ->on('products')
        ->onDelete('cascade');
});

Run the migrate artisan command and head over to your database manager and you should see the users, products and reviews table.

$ php artisan migrate

Model Relationships

While writing our migrations, we could see there's a relationship between the users, products and reviews table. Laravel ships with Eloquent ORM which provides a beautiful simple implementation for interacting with our tables.

Let's see what relationship exists between this tables.

  • A user can add many products but a product can only belong to one user. This is a one to many relationship between user and product.
  • A product can have many reviews but a review can only belong to one product. This is a one to many relationship between product and review.
  • And finally user can make many reviews (be it same or different products) but a review can only belong to one user. This is a one to many relationship between the user and review.

Now that we can see the relationship, let's define this in our model. The reason we are doing this is that eloquent makes managing and working with relationships easy, and supports a lot of relationship types.

In the User model (app\User.php), let us define the relationship between the user and the other models.

<?php

namespace App;

use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

class User extends Authenticatable
{
    use Notifiable;

    // Rest omitted for brevity

    /**
    * Get the products the user has added.
    */
     public function products()
     {
         return $this->hasMany('App\Product');
     }

     /**
     * Get the reviews the user has made.
     */
     public function reviews()
     {
    return $this->hasMany('App\Review');
     }
}

On the product model we will define the relationship between the product and the review model, and also the inverse relationship to the user model.

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Product extends Model
{
    /**
     * Get the reviews of the product.
     */
    public function reviews()
    {
        return $this->hasMany('App\Review');
    }

    /**
     * Get the user that added the product.
     */
    public function user()
    {
        return $this->belongsTo('App\User');
    }
}

On the review model we will define the inverse relationship to the user and product model.

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Review extends Model
{
    /**
     * Get the product that owns the review.
     */
    public function product()
    {
        return $this->belongsTo('App\Product');
    }

    /**
     * Get the user that made the review.
     */
    public function user()
    {
        return $this->belongsTo('App\User');
    }
}

Database Seeding

Remember the factory and seeder file created when we ran the make:model command with the -a flag? Now it's the time to see this files in action.

Database seeding in summary is just populating tables with data we want to develop. In essence this means having fake data which we can be used for but not limited to testing, demo, building routes or views to consume this data, initial setup etc. without the need to start inputting this data manually.

Laravel ships with a PHP faker library Faker which is an awesome library by the way and this is the library used in creating the fake data.

Opening the UserFactory file which is included in by default, we see that how this works is we define the model we want to generate fake data for and return an associative array in which the key is the attributes on the model and the value is the relevant property or method on the faker library.

Now it's time to do same for the ProductFactory

<?php

/** @var \Illuminate\Database\Eloquent\Factory $factory */

use App\Product;
use App\User;
use Faker\Generator as Faker;

$factory->define(Product::class, function (Faker $faker) {
    return [
        'name' => $faker->word,
        'description' => $faker->paragraph,
        'price' => $faker->numberBetween(1000, 20000),
        'user_id' => function() {
            return User::all()->random();
        },
    ];
});

And ReviewFactory

<?php

/** @var \Illuminate\Database\Eloquent\Factory $factory */

use App\Review;
use App\User;
use Faker\Generator as Faker;

$factory->define(Review::class, function (Faker $faker) {
    return [
        'review' => $faker->paragraph,
        'rating' => $faker->numberBetween(0, 5),
        'user_id' => function() {
            return User::all()->random();
        },
    ];
});

Next is to write out our seeders. According to laravel's documentation

A seeder class only contains one method by default: run. This method is called when the db:seed Artisan command is executed. Within the run method, you may insert data into your database however you wish or use model factories to conveniently generate large amounts of database records.

While laravel comes with default user model, migration and factory files, it doesnt include a user seeder class. So we need to create one by running the command:

$ php artisan make:seeder UserSeeder

This will create a UserSeeder.php file in the database\seeds folder. Open the file and edit

/**
 * Run the database seeds.
 *
 * @return void
 */
public function run()
{
    factory(App\User::class, 50)->create();
}

We are using the factory helper method to insert 50 user records into the users table.

To run our seeders, open the database\seeds\DatabaseSeeder.php file and uncomment the line

$this->call(UserSeeder::class);

Then we can use the db:seed artisan command to seed the database. Head over to your users and you should see 50 user records created.

$ php artisan db:seed

Cool! Let's do the same for the ProductSeeder with a little twist

<?php

use Illuminate\Database\Seeder;

class ProductSeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        factory(App\Product::class, 100)->create()->each(function ($product) {
            $product->reviews()->createMany(factory(App\Review::class, 5)->make()->toArray());
        });
    }
}

No magic here, what we doing is simply creating 100 products and for each product we are creating 5 reviews for that product.

To call additional seeders in our DatabaseSeeder.php file, we pass an array instead to the call method

$this->call([
    UserSeeder::class,
    ProductSeeder::class,
]);

Let's use the migrate:fresh command, which will drop all tables and re-run all of our migrations. This command is useful for completely re-building our database. The --seed flag instructs laravel to seed the database once the migration is completed.

$ php artisan migrate:fresh --seed

Check your tables and Voila! I know this section might be overwhelming. If you are feeling overwhelmed by database seeding, I'll recommend going through laravel's documentation or search online for materials (there are a lot out there).

Authentication

To secure our application we will be using the package jwt-auth for authentication with JSON Web Tokens (JWT). Run the command to install the package latest version:

$ composer require tymon/jwt-auth

Note: If you are using Laravel 5.4 and below, you will need to manually register the service provider. Add the service provider to the providers array in the config/app.php config file as follows:

'providers' => [

    ...

    Tymon\JWTAuth\Providers\LaravelServiceProvider::class,
]

Next is to publish the package config file:

$ php artisan vendor:publish --provider="Tymon\JWTAuth\Providers\LaravelServiceProvider"

You should now have a config/jwt.php file that allows you to configure the basics of this package.

Next is to run the command below to generate a secret key. This secret key will be used to sign our tokens.

$ php artisan jwt:secret

This will update the .env file with something like JWT_SECRET=value

Before we can start authenticating our users using JWT we need to update the user model to implement the Tymon\JWTAuth\Contracts\JWTSubject contract, which requires we implement the 2 methods getJWTIdentifier() and getJWTCustomClaims().

<?php

namespace App;

use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Tymon\JWTAuth\Contracts\JWTSubject;

class User extends Authenticatable implements JWTSubject
{
        // Rest omitted for brevity

    /**
     * Get the identifier that will be stored in the subject claim of the JWT.
     *
     * @return mixed
     */
    public function getJWTIdentifier()
    {
        return $this->getKey();
    }

    /**
     * Return a key value array, containing any custom claims to be added to the JWT.
     *
     * @return array
     */
    public function getJWTCustomClaims()
    {
        return [];
    }
}

To use laravel's built in auth system, with jwt-auth doing the heavy lifting we need to make a few changes to the config/auth.php.

'defaults' => [
    'guard' => 'api',
    'passwords' => 'users',
],

...

'guards' => [
    'api' => [
        'driver' => 'jwt',
        'provider' => 'users',
    'hash' => false,
    ],
],

Here we are telling the api guard to use the jwt driver, and setting the api guard as the default auth guard.

Now we can begin implementing the authentication logic into our application. Let's start by creating an AuthController

$ php artisan make:controller AuthController

Let's add a few methods to this controller.

<?php

namespace App\Http\Controllers;

use App\User;
use Illuminate\Http\Request;

class AuthController extends Controller
{
    public function __construct()
    {
      $this->middleware('auth')->except(['register', 'login']);
    }

    public function register(Request $request)
    {
        $request->validate([
            'name' => 'required|string',
            'email' => 'required|string|unique:users',
            'password' => 'required|string|min:8',
        ]);

        $user = User::create([
            'name' => $request->name,
            'email' => $request->email,
            'password' => bcrypt($request->password),
        ]);
        $token = auth()->login($user);
        return $this->respondWithToken($token);
    }

    public function login(Request $request)
    {
        $request->validate([
            'email' => 'required|string',
            'password' => 'required|string',
        ]);
        $credentials = $request->only(['email', 'password']);
        if (!$token = auth()->attempt($credentials)) {
            return response()->json(['error' => 'Invalid Credentials'], 401);
        }
        return $this->respondWithToken($token);
    }

    protected function respondWithToken($token)
    {
        return response()->json([
            'access_token' => $token,
            'token_type' => 'bearer',
            'expires_in' => auth()->factory()->getTTL() * 60
        ]);
    }

    /**
     * Get the authenticated User.
     *
     * @return \Illuminate\Http\JsonResponse
     */
    public function me()
    {
        return response()->json(auth()->user());
    }

    /**
     * Log the user out (Invalidate the token).
     *
     * @return \Illuminate\Http\JsonResponse
     */
    public function logout()
    {
        auth()->logout();
        return response()->json(['message' => 'Successfully logged out']);
    }

}

We use the auth middleware to only allow authenticated users to access the methods while exempting the register and login methods. The register method creates and stores a new user into the database while the login method which logs user into the application. This two methods returns a JWT response by calling the respondWithToken() method. The me method returns the currently authenticated user while the logout method logs out the currently authenticated user from the application.

Let's define our api routes. Open up the routes/api.php and add the following routes.

Route::get('me', 'AuthController@me');
Route::post('login', 'AuthController@login');
Route::post('register', 'AuthController@register');
Route::post('logout', 'AuthController@logout');

Test the endpoints and we should see authentication working.

Product Endpoints

Let's begin by defining the product endpoints.

  • GET /products - Fetch all products
  • GET /products/:id - Fetch a single product and its reviews
  • POST /products - Create a product
  • PUT /products/:id - Update a product
  • DELETE /products/:id - Delete a product

In our routes/api.php file we can define this routes individually or use laravel resource routing feature or even better still use the api resource route feature. According to the laravel docs

When declaring resource routes that will be consumed by APIs, you will commonly want to exclude routes that present HTML templates such as create and edit. For convenience, you may use the apiResource method to automatically exclude these two routes:

So we add the products routes in the route file:

Route::apiResource('products', 'ProductController');

In the ProductController.php file

<?php

namespace App\Http\Controllers;

use App\Product;
use App\Review;
use Illuminate\Http\Request;

class ProductController extends Controller
{
    public function __construct()
    {
      $this->middleware('auth')->except(['index', 'show']);
    }

    /**
     * Display a listing of the resource.
     *
     * @return \Illuminate\Http\Response
     */
    public function index()
    {
        $products = Product::with('user:id,name')
            ->withCount('reviews')
            ->latest()
            ->paginate(20);
        return response()->json(['products' => $products]);
    }

    /**
     * Store a newly created resource in storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function store(Request $request)
    {
        $request->validate([
            'name' => 'required|string',
            'description' => 'required|string',
            'price' => 'required|numeric|min:0',
        ]);

        $product = new Product;
        $product->name = $request->name;
        $product->description = $request->description;
        $product->price = $request->price;

        auth()->user()->products()->save($product);
        return response()->json(['message' => 'Product Added', 'product' => $product]);
    }

    /**
     * Display the specified resource.
     *
     * @param  \App\Product  $product
     * @return \Illuminate\Http\Response
     */
    public function show(Product $product)
    {
        $product->load(['reviews' => function ($query) {
            $query->latest();
        }, 'user']);
        return response()->json(['product' => $product]);
    }

    /**
     * Update the specified resource in storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \App\Product  $product
     * @return \Illuminate\Http\Response
     */
    public function update(Request $request, Product $product)
    {
        if (auth()->user()->id !== $product->user_id) {
            return response()->json(['message' => 'Action Forbidden']);
        }
        $request->validate([
            'name' => 'required|string',
            'description' => 'required|string',
            'price' => 'required|numeric',
        ]);

        $product->name = $request->name;
        $product->description = $request->description;
        $product->price = $request->price;
        $product->save();

        return response()->json(['message' => 'Product Updated', 'product' => $product]);
    }

    /**
     * Remove the specified resource from storage.
     *
     * @param  \App\Product  $product
     * @return \Illuminate\Http\Response
     */
    public function destroy(Product $product)
    {
        if (auth()->user()->id !== $product->user_id) {
            return response()->json(['message' => 'Action Forbidden']);
        }
        $product->delete();
        return response()->json(null, 204);
    }
}

In the constructor we are using the auth middleware but exempting the index and show methods from using the middleware. This means unauthenticated users will be able to view all products and a single product which is okay for our api.

The index method, returns a list of products ordered by the created date, the number of reviews the product has and paginates the records.

The store method validates the request input and then creates a new product and attaches the product to the currently authenticated user and returns the newly created product.

The show method returns a single product, the creator(user) and its reviews.

The update method checks the currently authenticated user trying to update the record is the user that created the record. If the user is not the creator, a forbidden response is returned else we carry out the update operation.

The delete method checks the currently authenticated user trying to delete the record is the user that created the record. If the user is not the creator, a forbidden response is returned else we carry out the delete operation.

Review Endpoints

Let's begin by defining the review endpoints.

  • POST /products/:id/reviews - Create a review for a product
  • PUT /products/:id/reviews/:id - Update a product review
  • DELETE /products/:id/reviews/:id - Delete a product review

We define the apiResource route for the reviews and also specifying actions the controller should handle instead of the full set of default actions.

Route::apiResource('products/{product}/reviews', 'ReviewController')
    ->only('store', 'update', 'destroy');

In the ReviewController.php file

<?php

namespace App\Http\Controllers;

use App\Product;
use App\Review;
use Illuminate\Http\Request;

class ReviewController extends Controller
{
    public function __construct()
    {
      $this->middleware('auth');
    }

    /**
     * Store a newly created resource in storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \App\Product  $product
     * @return \Illuminate\Http\Response
     */
    public function store(Request $request, Product $product)
    {
        $request->validate([
            'review' => 'required|string',
            'rating' => 'required|numeric|min:0|max:5',
        ]);

        $review = new Review;
        $review->review = $request->review;
        $review->rating = $request->rating;
        $review->user_id = auth()->user()->id;

        $product->reviews()->save($review);
        return response()->json(['message' => 'Review Added', 'review' => $review]);
    }

    /**
     * Update the specified resource in storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \App\Product  $product
     * @param  \App\Review  $review
     * @return \Illuminate\Http\Response
     */
    public function update(Request $request, Product $product, Review $review)
    {
        if (auth()->user()->id !== $review->user_id) {
            return response()->json(['message' => 'Action Forbidden']);
        }
        $request->validate([
            'review' => 'required|string',
            'rating' => 'required|numeric|min:0|max:5',
        ]);

        $review->review = $request->review;
        $review->rating = $request->rating;
        $review->save();

        return response()->json(['message' => 'Review Updated', 'review' => $review]);
    }

    /**
     * Remove the specified resource from storage.
     *
     * @param  \App\Product  $product
     * @param  \App\Review  $review
     * @return \Illuminate\Http\Response
     */
    public function destroy(Product $product, Review $review)
    {
        if (auth()->user()->id !== $review->user_id) {
            return response()->json(['message' => 'Action Forbidden']);
        }
        $review->delete();
        return response()->json(null, 204);
    }
}

Conclusion

That’s it! In this tutorial, we have built a simple api and covered authentication, database seeding and CRUD operation.

The complete code for this tutorial is available on GitHub.

Posted on by:

mr_steelze profile

Sholley O.

@mr_steelze

Backend developer passionate about building and fixing stuffs, and good music.

Discussion

markdown guide