Laravel Shop tutorial #1 – Building a review system

The first tutorial in “Building a shop” series is going to be a practical example of implementing a rating/review system into an application. Review systems or rating systems are very common in all kinds of e-Commerce projects, social networks, anything that requires user’s input to be some sort of concrete value on some scale.

DEMO: http://demos.maxoffsky.com/shop-reviews Source on Github : https://github.com/msurguy/laravel-shop-reviews

A very common example of a review system is when the user can submit a rating of 1-5 stars and a comment that goes with the rating like on Etsy.com:

Imagine this, you have an online shop that has products, products have reviews that consist of a rating and a comment.

This is the idea that we will replicate in this tutorial.

Here’s how this tutorial will follow the steps to reach that goal:

  1. See the Demo of the project in action to get an idea of what you will make (the link is above at the beginning of tutorial)
  2. Think about efficiency
  3. Define the routes for showing the products, showing individual product and submitting the reviews
  4. Define the database structure and the models for the products and for the reviews
  5. Create the view templates to display the shop home page and the individual product page.
  6. Create functions that will calculate and store ratings/reviews
  7. Create logic for the defined routes
  8. Refactor the code if necessary

I have looked at lots of jQuery plugins that make it a bit easier to create a nice user experience for rating a product by the number of stars/hearts, and I have found this little plugin that does the job efficiently: https://github.com/dobtco/starrr I have modified it to work with Bootstrap 3 and the modified version is included in the source code of this application.

What are the difficulties of implementing such a review system?

Well, first of all efficiency is an important factor that we will have to include in making architectural decisions. As an example, let’s think about how the product will store user’s reviews.

One way to store reviews is to store everything separately – the rating, the comment and the product each in a separate database table. We could relate the rating to the comment (one to one relationship) and then store the comment under a product as one to many relationship (a product has many reviews).

A disadvantage of this way of storing the data for reviews is that there is quite a bit of overhead (a bit too much nesting which mean lots of inefficient SQL joins). Why don’t we combine the rating and the comment into one entity and call it “Review”? Perhaps this will be a better structure for storing the comment and rating data together.

When the user submits a review, we will store both a rating (consisting of number of stars the user gives this product) and a comment (perhaps the user has something to say about the product) as a new row in table designated to store our reviews.

We also need to somehow calculate an average rating for each product based on submitted reviews and show it on product’s page and on the page that lists all products.

Online shops usually have pages that display many products at once. Take for example the homepage that shows a list of latest products. It is vital for shop’s performance that displaying products and a review count/rating will be a quick task for our application, with as few SQL queries as possible.

Taking this into an account, if we try to calculate the average rating for the product on the fly we will run into performance issues. Therefore as a step of precaution, let’s save the calculated ratings in the products table (for example as rating_cache) as well as saving all the reviews in the designated table. This way when we retrieve a product from the database, we will have all necessary information to display right away instead of executing too many unnecessary SQL queries.

Well, enough talking! Let’s see the application in action! I have made the code available on Github here and the live application is at http://demos.maxoffsky.com/shop-reviews play with it, leave some reviews for products and see how the application feels.

As we have thought about the efficiency and have checked out the demo of the application in action, let’s define the routes for the application.

Defining the routes for the shop homepage, displaying the product and submitting a review.

Our application will have 3 simple routes, a route that shows all products, a route that shows individual product and a a route that is executed upon submittal of a review.

Let’s open up the routes.php file and create a placeholder for these routes:

<?php

// Route for Homepage - displays all products from the shop
Route::get('/', function()
{
  return 'Shop homepage';
});

// Route that shows an individual product by its ID
Route::get('products/{id}', function($id)
{
  return 'Product: '.$id;
});

// Route that handles submission of review - rating/comment
Route::post('products/{id}', array('before'=>'csrf', function($id)
{	
  return 'Review submitted for product '.$id;
}));

This seems like a good start. These route placeholders will be filled out as we are progressing through the tutorial. Now let’s define what the database for this application will look like and what its data models will look like.

Defining the database structure and the data models:

Let’s think a bit about where the data will live for the application. I have created a simple diagram showing the different components of the product and reviews that users will leave on the product. Please look at the diagram below.

Let’s define a database structure that will store the data according to the diagram above.

The database will have the following tables:

  • Users (to store user information like name, email, password to log in)
  • Products (Will store product – related data such as name, descriptions, icon for the product, and finally rating cache and rating count)
  • Reviews (Will store reviews that users will leave for the product)

Here is the database structure visualized (click to see it bigger):

And here is the SQL for the database, including some sample data for the products:

CREATE TABLE `products` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `published` tinyint(1) NOT NULL DEFAULT '0',
  `rating_cache` float(2,1) unsigned NOT NULL DEFAULT '3.0',
  `rating_count` int(11) unsigned NOT NULL DEFAULT '0',
  `name` varchar(255) NOT NULL,
  `pricing` float(9,2) unsigned NOT NULL DEFAULT '0.00',
  `short_description` varchar(255) NOT NULL,
  `long_description` text NOT NULL,
  `icon` varchar(255) NOT NULL,
  `created_at` datetime NOT NULL,
  `updated_at` datetime NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB  DEFAULT CHARSET=latin1 AUTO_INCREMENT=1 ;

INSERT INTO `products` (`id`, `published`, `rating_cache`, `rating_count`, `name`, `pricing`, `short_description`, `long_description`, `icon`, `created_at`, `updated_at`) VALUES
(1, 1, 3.0, 0, 'First product', 20.99, 'This is a short description asdf as This is a short description asdf as', 'Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum', '', '2013-11-06 05:11:00', '2013-11-12 05:51:07'),
(2, 1, 3.0, 0, 'Second product', 55.00, 'This is a short description', 'Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum', '', '2013-11-06 05:11:00', '2013-11-11 16:17:23'),
(3, 1, 3.0, 0, 'Third product', 65.00, 'This is a short description', 'Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum', '', '2013-11-06 05:11:00', '2013-11-06 06:08:00'),
(4, 1, 3.0, 0, 'Fourth product', 85.00, 'This is a short description', 'Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum', '', '2013-11-06 05:11:00', '2013-11-06 06:08:00'),
(5, 1, 3.0, 0, 'Fifth product', 95.00, 'This is a short description', 'Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum', '', '2013-11-06 05:11:00', '2013-11-06 06:08:00');

CREATE TABLE `reviews` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `product_id` int(11) NOT NULL,
  `user_id` int(11) NOT NULL,
  `rating` int(11) NOT NULL,
  `comment` text NOT NULL,
  `approved` tinyint(1) unsigned NOT NULL DEFAULT '1',
  `spam` tinyint(1) unsigned NOT NULL DEFAULT '0',
  `created_at` datetime NOT NULL,
  `updated_at` datetime NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1 AUTO_INCREMENT=1 ;

CREATE TABLE `users` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `user_type` int(10) unsigned NOT NULL DEFAULT '0',
  `email` varchar(128) NOT NULL,
  `password` varchar(128) NOT NULL,
  `created_at` datetime NOT NULL,
  `updated_at` datetime NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=MyISAM DEFAULT CHARSET=latin1 AUTO_INCREMENT=1 ;

Whew! Now we can relate all this data together using Laravel’s models. Shall we?!

First, let’s create the model for Products that will relate reviews by one to many relationship, name it “Product.php” and place it into app/models folder:

<?php

class Product extends Eloquent
{
  public function reviews()
  {
	return $this->hasMany('Review');
  }
}

Then let’s relate the reviews to the products, to users and create some scopes for convenience, each review belongs to one user, and review can belong to only one product, the scopes will serve as shortcuts when we need to filter out reviews that are marked as spam(let’s say by the administrator), place the following into “app/models/Review.php“:

<?php

class Review extends Eloquent
{

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

  public function product()
  {
    return $this->belongsTo('Product');
  }

  public function scopeApproved($query)
  {
    return $query->where('approved', true);
  }

  public function scopeSpam($query)
  {
    return $query->where('spam', true);
  }

  public function scopeNotSpam($query)
  {
    return $query->where('spam', false);
  }
}

This is all we need for our relationships! We will add some additional functions into the models a bit later to do the necessary rating calculations/storing of ratings, but for now this will be enough for us to continue. Up next, creating view templates for displaying the products and the reviews.

Creating view templates to show products and reviews

I assume you know a thing or two about Laravel at this point so I won’t get too much into building templates and layouts, but couple things I wanted to mention is that for the review UI I have created a snippet on my Bootsnipp titled “Expanding review and rating box” which you can see below (using Bootstrap 3 for the user interface):

And for outputting user submitted reviews as a part of the product template I use the following foreach loop:

@foreach($reviews as $review)
  <hr>
  <div class="row">
    <div class="col-md-12">
    @for ($i=1; $i <= 5 ; $i++)
      <span class="glyphicon glyphicon-star{{ ($i <= $review->rating) ? '' : '-empty'}}"></span>
    @endfor

    {{ $review->user ? $review->user->name : 'Anonymous'}} <span class="pull-right">{{$review->timeago}}</span> 

    <p>{{{$review->comment}}}</p>
    </div>
  </div>
@endforeach

The $review->timeago is a presenter function that I will add to the app/models/Review.php:

// Attribute presenters
...
public function getTimeagoAttribute()
{
  $date = CarbonCarbon::createFromTimeStamp(strtotime($this->created_at))->diffForHumans();
  return $date;
}
...

Also, you might have noticed the triple brace used to output the $review->comment. This is Laravel’s way to not allow things like <script></script> in displaying the comments. I chose to not filter the comment data on input but rather when the comment is displayed by using Blade’s triple braces trick.

You can see the complete view templates at https://github.com/msurguy/laravel-shop-reviews.

At this point there are only few things left to finish, store the user’s reviews somehow and also connect all the routes to the functionality that we have built.

Creating helper functions to store reviews and calculate ratings

First, let’s create a helper function (let’s call it storeReviewForProduct) that will do the following :

  • take product ID, user submitted comment and rating
  • attach the comment and the rating as a child model to the product
  • call a function to recalculate all ratings for the product

This function will be a part of the app/models/Review.php model, so let’s add it there:

...

public function getTimeagoAttribute()
{
  ...
}

// this function takes in product ID, comment and the rating and attaches the review to the product by its ID, then the average rating for the product is recalculated
public function storeReviewForProduct($productID, $comment, $rating)
{
  $product = Product::find($productID);

  // this will be added when we add user's login functionality
  //$this->user_id = Auth::user()->id;

  $this->comment = $comment;
  $this->rating = $rating;
  $product->reviews()->save($this);

  // recalculate ratings for the specified product
  $product->recalculateRating();
}

For now we will not save logged in user’s id along with the comment simply because we have not built a login system.

As you might have noticed, we are calling recalculateRating function on the product instance, that function is not defined yet, so let’s create it in the app/models/Product.php model (I include comments that explain what happens when that function is executed):

<?php

class Product extends Eloquent
{
  public function reviews()
  { ... }

  // The way average rating is calculated (and stored) is by getting an average of all ratings, 
  // storing the calculated value in the rating_cache column (so that we don't have to do calculations later)
  // and incrementing the rating_count column by 1

  public function recalculateRating()
  {
    $reviews = $this->reviews()->notSpam()->approved();
    $avgRating = $reviews->avg('rating');
    $this->rating_cache = round($avgRating,1);
    $this->rating_count = $reviews->count();
    $this->save();
  }
}

Alright. The major part of the application is done! The only thing left is to connect all of our routes so that the applications becomes alive!

Connecting routes to views

In the beginning of the tutorial we have defined three route placeholders for the application, one that shows all products, one that shows an individual product and one that’s handling the review submission. Now let’s fill those routes with some logic that the application needs.

Home page route only needs to display all products from the shop:

// Route for Homepage - displays all products from the shop
Route::get('/', function()
{
  $products = Product::all();
  return View::make('index', array('products'=>$products));
});

The route to display a single product (when you click on a product from the homepage) needs to do two things, it needs to retrieve a product by id specified in the route parameter and it needs to retrieve a paginated collection of reviews that are marked as approved and not spam:

// Route that shows an individual product by its ID
Route::get('products/{id}', function($id)
{
  $product = Product::find($id);
  // Get all reviews that are not spam for the product and paginate them
  $reviews = $product->reviews()->with('user')->approved()->notSpam()->orderBy('created_at','desc')->paginate(100);

  return View::make('products.single', array('product'=>$product,'reviews'=>$reviews));
});

And lastly, when the review is submitted from the product page, we will validate that the review corresponds to validation rules(I put them in the Review model to make the route a bit cleaner), and if the validation passes, we will store the review in DB and redirect the user back to the product page with a message that the review has been posted:

// Route that handles submission of review - rating/comment
Route::post('products/{id}', array('before'=>'csrf', function($id)
{
  $input = array(
	'comment' => Input::get('comment'),
	'rating'  => Input::get('rating')
  );
  // instantiate Rating model
  $review = new Review;

  // Validate that the user's input corresponds to the rules specified in the review model
  $validator = Validator::make( $input, $review->getCreateRules());

  // If input passes validation - store the review in DB, otherwise return to product page with error message 
  if ($validator->passes()) {
	$review->storeReviewForProduct($id, $input['comment'], $input['rating']);
	return Redirect::to('products/'.$id.'#reviews-anchor')->with('review_posted',true);
  }

  return Redirect::to('products/'.$id.'#reviews-anchor')->withErrors($validator)->withInput();
}));

For a complete routes.php file please look in the application repository. I have omitted showing complete blade templates in this post as they are quite large, you can see them in the repository as well.

Refactoring ideas :
1) Move the logic from the routes to controllers (for example create shop controller and product controller and display index page from shop controller and all things related to individual product from a product controller).
2) Clean up the models by separating view presenters scopes and relationships.
3) Prevent the same user from submitting more than one review on a product (when you have a login system implemented first)

At this point the application should work as intended and you will be able to have users submit reviews to your shop/gallery/whatever website!

This concludes the first part of “Building a shop” comprehensive tutorial! Do you like the series? Do you want more of comprehensive tutorials like this? Let me know in the comments and make sure to follow me on Twitter!