Project and Database Setup0:00
Today, we're answering a question from the Laracast forum. How do we build a star rating system? So, you've seen it many times. You can choose from one to five stars for an article or a video or whatever you want. Let's figure out how over the next two lessons. We'll start from scratch. Maybe we want to rate articles, so we'll call it ArticleRating. Okay, and we'll cd in there and get started. Now if I open this in my editor, the first thing I want to do is set up a database. This is basic, so I'm going to use an sqlite database, and we'll use the default path.
Now if I open this in my editor, the first thing I want to do is set up a database. This is basic, so I'm going to use an sqlite database, and we'll use the default path. Okay, now if I try to migrate though, it's going to squawk. So if we zoom out, we can see this database.sqlite file does not exist. Alright, let's create it, and then run it again. Okay, now we've whipped up the default migrations. Now we're going to be rating articles or blog posts, so let's make a model called Article, but I also want a migration for it and also a database factory. Okay, so let's start with the migration. Create articles table, and again this will be incredibly simple.
Create Article Model1:09
Okay, so let's start with the migration. Create articles table, and again this will be incredibly simple. We'll say an Article consists of a title and also the body, which will be a text column. Next I'm going to go into my database factory and set up the blueprint, so to speak. So a title would just be like a fake sentence, and this will be useful for testing by the way. And then the body will be a fake paragraph. Okay, so now if we ever want to whip up a quick Article, we can do so in no time. So I think we're going to illustrate this with a test, it'll make it a little more clear for us.
Write Initial Rating Test1:44
So I think we're going to illustrate this with a test, it'll make it a little more clear for us. We'll call it ArticleRating. So this will go within your tests/Feature, there it is, and actually I'm sorry that should be ArticleRatingTest. Okay, so we want to refresh our database when we're done, and we'll get rid of all of this and start from scratch. So what's the most basic example of what this needs to do? Well, an Article, and we'll call it article, it can be rated. So what we're going to say is, well given we have an Article, like so, then if I want
Well, an article, and we'll call it Article, it can be rated. So what we're going to say is, well given we have an Article, like so, then if I want to rate it, maybe something like this. Very very simple. This is how I want my API to work. So now if we want to say, well at this point, how many ratings should there be? There should be one rating for this Article. So why don't we start with that? Let's say there should be exactly one rating. Okay, so if I fetch this relationship, it should be a collection of one item at the
Let's say there should be exactly one rating. Okay, so if I fetch this relationship, it should be a collection of one item at the moment. So if I give this a run, it fails. There is no method rate on the Article class. Of course not. So we'll go there, add our method rate, and then run the test again. Assert count must be countable or iterable. Okay, fair enough. So we're trying to fetch this ratings relationship, that's just returning null, and so we're trying.
Add Ratings Relationship3:03
Okay, fair enough. So we're trying to fetch this ratings relationship, that's just returning null, and so we're trying to count null, and of course you can't do that. So let's add our ratings method and give it another run. Okay, well we're expecting an Eloquent relationship there, but we haven't returned one. So we could use polymorphic relations so that you could rate anything, but just to keep things a little more simple, we're only going to rate articles for this episode. So we'll say, well an Article can have many, what, ratings, so maybe we can set up a model for that. Okay, so if we give that a run, we don't have the model.
for that. Okay, so if we give that a run, we don't have the model. All right, php artisan, give me a model for a Rating, and we do know that we'll have a migration as well for the ratings table. Okay, we'll give it another run. Now there is no article_id, so it's automatically running our migrations, but we haven't yet set that up. So let's do that now. We'll go to create ratings table, and for now a table consists of a rating. We could do something like that, or we could even say tinyInteger, or even unsignedTinyInteger
We'll go to create ratings table, and for now a table consists of a rating. We could do something like that, or we could even say tinyInteger, or even unsignedTinyInteger, or if you want, you could even use an enum column if you want. So if you want to say the only thing we'll accept is 1 through 5, we could do this, and that way you can't set it to anything other than those values. Whatever you want. But now if we are only and exclusively rating articles, then we do need a relationship there. So we'll have an unsignedSmallInteger would be fine, we'll just do that one though for the article_id. And then let's also say the foreign key article_id, that refers to the ID column on the articles.
article ID. And then let's also say the foreign key article_id, that refers to the ID column on the articles table. And if we happen to delete the article, let's cascade down and delete all of the ratings as well. Okay, so now if we give this test a run again, we've changed the error. So we are making progress. So it looks like the current size is 0, but we expected it to be 1. So let's take a look. We tried to give it a rating, and then when we fetched the collection of ratings, we expected
So let's take a look. We tried to give it a rating, and then when we fetched the collection of ratings, we expected there to be one item at that point, but it's giving us 0. Okay, let's see why. We'll go back to Article, and of course, when we call this rate method, we don't do anything. So let's accept the rating, and then we'll say $this->ratings create where the rating is equal to what you gave us. Okay, let's run the test again, and now we get a mass assignment exception. Generally, we've talked about this before in other series. I'm responsible enough.
Calculate Average Rating5:40
Generally, we've talked about this before in other series. I'm responsible enough. I'm happy to set this to an empty array. I don't want Laravel to automatically protect me from mass assignment. Okay, so we're going to give this another run, and ta-da, it returns green. Okay, let's switch back. So now we know it can be rated, but maybe it can also determine the average rating. So it can calculate the average rating. So for example, if all five of us rate it 5, then the average rating is 5. If one of us rates it a 5, and the other rates it a 1, then the average rating should be
So I would expect that to give us 3. Okay, so we'll give that a run, and it fails. So at the moment, it's returning null because we don't have a rating method yet. Okay, so let's close these out, and we'll say right here. So if you want the rating, we're going to say, well, give me all of the ratings for this particular article, and then on the collection object, we could even do it as a query, as a SQL query, but I'm just going to do it on the collection end for now. We'll get the average rating. So for this particular attribute, give me the average of all of them, but yet later if we want, we could do it as a database query, and that might be better.
So for this particular attribute, give me the average of all of them, but yet later if we want, we could do it as a database query, and that might be better. And if we run it, rating must return a relationship instance. No it shouldn't. Article, oh, whoops, we're calling this one as a method. Okay, so now if we run it, we get green. Great, so that works. Next we have protection on the database end that you can't rate below 1 or above 5, but let's also handle it on the php side. So it cannot be rated above 5.
Validate Rating Range7:44
let's also handle it on the php side. So it cannot be rated above 5. So let's say right here, if we were to rate it a 6, I'm going to expect an Exception, because we're dealing with a star rating system here, and you have 1 star through 5 stars. So here I will expect an Exception, and InvalidArgumentException is fine for now. Okay, so if we give this one a run, it's going to fail. So failed asserting, okay, so an Exception was actually thrown on the database end, and that's because we already have protection there. But we want to catch it sooner than that. So let's fix that.
But we want to catch it sooner than that. So let's fix that. We'll say right here, when you call the rate method, this is a fine enough place to protect the consistency. So I could say if the $rating is greater than 5, then throw a new InvalidArgumentException. Ratings must be between 1 to 5. Alright, let's give it another run, and it passes. Let's do another one here. It cannot be rated below 1. So you can't do a 0 rating here.
It cannot be rated below 1. So you can't do a 0 rating here. It's 1 through 5. So if I did negative 1 or something like that, I also expect an exception. We're getting the same thing as before. Alright, let's say if it's greater than 5, or the rating is less than 1, then throw an exception. And in fact, let's clean up. Okay, run the test again, and now we're back to green. Okay, great, we're making progress.
Enforce One Rating per User9:28
A user can only rate an Article a single time. So we don't want to allow a User to spam the rating system, or maybe every time they refresh the page or they submit a POST request, and they keep passing 5 to increase their rating or something like that. So let's say a User may only rate, or how about this, because we're talking about Articles, it can only be rated once per User. So a User can rate any number of Articles, but for each one, they can only do a single rating. Okay, so let's say here, let's steal something, let's steal this. Okay, so given we have an Article, and we're going to rate it, so now we need some association.
Okay, so let's say here, let's steal something, let's steal this. Okay, so given we have an Article, and we're going to rate it, so now we need some association between a Rating and the User. Now, I think we can do this in two ways. Maybe we can even offer both. I like that we can just call rate 5, and maybe behind the scenes, it will use the authenticated User. But maybe you can also give it a second argument that would be the User who is performing the rate, or maybe even something like this, User, maybe we add the API to the User, so User::rate(Article, 5).
rate, or maybe even something like this, User, maybe we add the API to the User, so User rate article 5. That would be an option as well. In general, I like to be careful about adding too much to User, so I'm more inclined to offer this system here with an optional second argument. Okay, so let's start with using the authenticated User. So given we have some kind of User, let's pull this in. Okay, create some random User and sign them in. Okay, so now, when I call this method, I expect that authenticated User to be the one associated with the rating.
Okay, so now, when I call this method, I expect that authenticatedUser to be the one associated with the rating. So if they rate the article 5, and then they try to rate it 1 again, well, it's only going to use that most recent one. So I expect cert equals 1 to be the rating, so articleRating. Okay, so let's give that a run, and at the moment, it's still giving us the average because it's calculating this twice, 5 and 1, and then the average is 3. But it's the same user applying the rating, so it should ignore this the second time they update their rating. Okay?
update their rating. Okay? So let's see. It sounds like here, when we call rate, we need to change this up a little bit. Maybe we should say, I'm going to rewrite this. So we want the ratings. Actually, you know what? I'm going to do this the long-form way, and then I'm going to show you how to refactor it. So imagine you were just doing the Rating model directly, and you wanted to say rating.
it. So imagine you were just doing the Rating model directly, and you wanted to say rating where the user_id is the authenticated user, and the article_id is the id of the current article. And I kind of want to say, give me firstOrNew. So actually, there is a method called firstOrNew, which is useful for your model. So you can say, look for a record with these two attributes, or look for a row in the ratings table, and if you find it, just return it to me. If you don't find it, then give me a new instance of Rating. So I'm going to save this to rating, but I don't want to overwrite the number here.
If you don't find it, then give me a new instance of Rating. So I'm going to save this to rating, but I don't want to overwrite the number here. So let's do this. We'll just call it number for now. Okay. So find our rating instance, and then we'll say, fill it with the updated rating, and then save it. Okay. So let's run it and see how we're doing. It did fail.
So let's run it and see how we're doing. It did fail. Table rating has no column user_id. Okay. Yeah. That's our next step. So we've now decided every rating has to be associated with a particular User, and that way we prevent them from being able to spam our rating system. So I'm going to return to our ratings table, and now it sounds like every rating belongs to a User.
So I'm going to return to our ratings table, and now it sounds like every rating belongs to a User. Now we can do the same constraint here if you want. That refers to the ID column on the users. If you delete the User, delete their ratings as well. Up to you. Anyways, let's run the test again. Aha! Now we get green. Okay.
Now we get green. Okay. So does that all make sense? Imagine the User hasn't rated an article. Okay. We get a new instance of Rating. So we fill up that instance with the user's rating, and we save it. But now imagine they tried to update their rating. Okay. Well, this time it does exist in the database.
Okay. Well, this time it does exist in the database. So we return that existing rating. We then update that current attribute, and then we save it. So if we want to refactor this now while everything is passing or while that test is passing, we could once again say ratings, and then we'll say firstOrNew. So we'll grab these two here and pull them in. Then we're going to fill it with the rating and save. So I think we should be able to switch to that if you like that. Are we still green?
So I think we should be able to switch to that if you like that. Are we still green? Yep. So that means I no longer have that rating variable. So I can bring this back to what we had earlier, and then I could do compact('rating'). Okay. Run the test. Still green. Okay. But I think at this point we might have broken some of the other tests because now this will
So what you can do here is say, well, if you give us a User, we'll use that. Otherwise we'll stick with the authenticated User. And that way you get that convenience, you get that nice API, but you can also keep it as testable as you want. So now I could say, well, if you gave us a User, then use that. Otherwise, just look into the session. Okay. So now if I give it a run, that works. So at this point, I'm noticing that every test whips up an article, and it's probably going to whip up a User as well.
So at this point, I'm noticing that every test whips up an Article, and it's probably going to whip up a User as well. So I think I want to go ahead and override the setUp method, like so. And then we'll say this article equals, and in fact I'll just grab these two here, and assign it. And the user as well. Okay. So now I can delegate this article, like so. So we're still good there. Yeah.
Okay. So now we can see for our rating system, it can be rated, it can calculate the average, it can't be above five, it can't be below one, and users can only rate each article one time. So now that we have our basic feature figured out, and we have the implementation in place, actually, you know what? Let's do this real quick if you have a moment, and then we'll finish up. This would be fine as well, but it's always useful to consider what alternatives Eloquent provides. So I've shown you two ways, but if we have first or new, and then we update it, we kind
Refactor with UpdateOrCreate17:56
provides. So I've shown you two ways, but if we have first or new, and then we update it, we kind of want something like update or create. That's really what we want. Update the existing one or create a new one if it doesn't exist, right? So in these cases, it can be useful to, if you don't know the method, just hunt around. So look for maybe update or, ah, update or create. So maybe that's what we need instead. We give it an attribute. So we try to find a record with those attributes, and then we call fill and save.
We give it an attribute. So we try to find a record with those attributes, and then we call fill and save. It's almost exactly what we already have. Let's see if I can just say update or create. So try to find a record with those attributes, and then fill and save the rating. So something like that. All right. Let's run all of the tests again. They still return green, so that's a decent refactor. And in fact, actually, we can even go further.
They still return green, so that's a decent refactor. And in fact, actually, we can even go further. We originally were having to specify the article ID when we were using the Rating model directly, but now because we're going through the relationship here, the article ID is applied automatically. So I can get rid of that, run it again, and it still returns green. So I'm now a little bit happier with this. So now, with all of this in place, in the next episode, we'll approach things from the outside in. If I make the correct request and I try to rate an actual Article, will everything work the way I expect?
