Click to share! ⬇️

How To Favorite A Model

It’s a pretty common feature to be able to “favorite”, or vote for something in a web application. The perfect example of this might be the website reddit, where users are able to favorite or vote for a particular link or comment. In our example application we have the idea of threads and replies, and it would be cool if a user could actually choose their favorite threads or replies. This concept could loosely be referred to as favoriting a model. In this tutorial, we will learn how to add this feature.


Create The Favorite Test Class

Let’s begin by creating a new class in the Feature Test directory of the application. It will be named FavoritesTest.php.
phpstorm enter a new file name

Once the new file is created, you can use the awesome Live Templates feature of PHP Storm to whip up the test class boilerplate like a crazed Ninja.
phpstorm live template example


Add a test method to the class

The first thing we want to do is to set up a test method. Figuring out what the test will actually do is kind of the hard part. We’ll start with the pseudocode of what we are trying to test.

  • Given there is a Reply
  • When a POST request is sent to the “favorites” endpoint
  • Then it should be recorded in the database

The early rendition of this will take the form of this code.

<?php

namespace TestsFeature;

use IlluminateFoundationTestingDatabaseMigrations;
use TestsTestCase;
use IlluminateFoundationTestingWithFaker;
use IlluminateFoundationTestingRefreshDatabase;

class FavoritesTest extends TestCase
{
    use DatabaseMigrations;

    public function test_an_authenticated_user_can_favorite_any_reply()
    {
        $reply = create('AppReply');

        $this->post('replies/' . $reply->id . '/favorites');

        $this->assertCount(1, $reply->favorites);
    }
}

Now we expect some failures when we start running the test, but that is ok. The failure messages drive what code we need to fix or create next.

vagrant@homestead:~/Code/forumio$ phpunit --filter test_an_authenticated_user_can_favorite_any_reply
PHPUnit 6.5.5 by Sebastian Bergmann and contributors.

E                                                                   1 / 1 (100%)

Time: 1.5 seconds, Memory: 8.00MB

There was 1 error:

1) TestsFeatureFavoritesTest::test_an_authenticated_user_can_favorite_any_reply
PHPUnitFrameworkException: Argument #2 (No Value) of PHPUnitFrameworkAssert::assertCount() must be a countable or traversable

/home/vagrant/Code/forumio/tests/Feature/FavoritesTest.php:20

ERRORS!
Tests: 1, Assertions: 0, Errors: 1.

We see the error of “assertCount() must be a countable or traversable” however I’m actually surprised the test even made it that far. It looks like this might one of those instances where a call to $this->withoutExceptionHandling() is required. If we add that code to the test, and re run it, we now get this error.

vagrant@homestead:~/Code/forumio$ phpunit --filter test_an_authenticated_user_can_favorite_any_reply
PHPUnit 6.5.5 by Sebastian Bergmann and contributors.

E                                                                   1 / 1 (100%)

Time: 755 ms, Memory: 8.00MB

There was 1 error:

1) TestsFeatureFavoritesTest::test_an_authenticated_user_can_favorite_any_reply
SymfonyComponentHttpKernelExceptionNotFoundHttpException: POST http://localhost/replies/1/favorites

This is more along the lines of what I would expect. There error means there is no route in the routes file yet. We will need to add that like so.

<?php

Route::get('/', function () {
    return view('welcome');
});

Auth::routes();

Route::get('/home', 'HomeController@index')->name('home');

Route::get('/threads', 'ThreadsController@index');
Route::get('/threads/create', 'ThreadsController@create');
Route::get('/threads/{channel}/{thread}', 'ThreadsController@show');
Route::post('/threads', 'ThreadsController@store');
Route::get('/threads/{channel}', 'ThreadsController@index');
Route::post('/threads/{channel}/{thread}/replies', 'RepliesController@store');
Route::post('/replies/{reply}/favorites', 'FavoritesController@store');

Running the test again shows us that the class does not exist. This would make sense actually since we are referencing the FavoritesController in the routes file, but we have not created it yet.

vagrant@homestead:~/Code/forumio$ phpunit --filter test_an_authenticated_user_can_favorite_any_reply
PHPUnit 6.5.5 by Sebastian Bergmann and contributors.

E                                                                   1 / 1 (100%)

Time: 826 ms, Memory: 8.00MB

There was 1 error:

1) TestsFeatureFavoritesTest::test_an_authenticated_user_can_favorite_any_reply
ReflectionException: Class AppHttpControllersFavoritesController does not exist

Create The FavoritesController Class

This of course means we need to go ahead and create that new class. Let’s do that quickly with artisan.
php artisan make favorites controller

The class is now created, our error for a missing class should now be fixed. Run the test again to see how things are going.

vagrant@homestead:~/Code/forumio$ phpunit --filter test_an_authenticated_user_can_favorite_any_reply
PHPUnit 6.5.5 by Sebastian Bergmann and contributors.

E                                                                   1 / 1 (100%)

Time: 804 ms, Memory: 8.00MB

There was 1 error:

1) TestsFeatureFavoritesTest::test_an_authenticated_user_can_favorite_any_reply
BadMethodCallException: Method [store] does not exist on [AppHttpControllersFavoritesController].

The error we receive is that the store method does not exist. The class is there, but now we need to add the method. Ok, sounds like a plan. We can add the store() method now.

<?php

namespace AppHttpControllers;

use IlluminateHttpRequest;

class FavoritesController extends Controller
{
    public function store()
    {
    }
}

This should fix the missing store method error. Run the test again!

vagrant@homestead:~/Code/forumio$ phpunit --filter test_an_authenticated_user_can_favorite_any_reply
PHPUnit 6.5.5 by Sebastian Bergmann and contributors.

E                                                                   1 / 1 (100%)

Time: 844 ms, Memory: 8.00MB

There was 1 error:

1) TestsFeatureFavoritesTest::test_an_authenticated_user_can_favorite_any_reply
PHPUnitFrameworkException: Argument #2 (No Value) of PHPUnitFrameworkAssert::assertCount() must be a countable or traversable

/home/vagrant/Code/forumio/tests/Feature/FavoritesTest.php:21

ERRORS!
Tests: 1, Assertions: 0, Errors: 1.

Ah ha. Now we are seeing that “assertCount() must be a countable or traversable” error message once again. Well, I know this, our store() method doesn’t even actually do anything yet so we are probably going to need to do some work on that method. We want to insert a record into a favorites table which has the user id of the person signed in, the id of the favorited reply, as well as the class name of the favorited type. This may look odd, but it is to allow for setting up a polymorphic relation where a user will be able to favorite more than one type of thing. They could favorite a reply, or a thread, or a user, etc…


<?php

namespace AppHttpControllers;

use AppReply;
use IlluminateHttpRequest;

class FavoritesController extends Controller
{
    public function store(Reply $reply)
    {
        return DB::table('favorites')->insert([
            'user_id' => auth()->id(),
            'favorited_id' => $reply->id,
            'favorited_type' => get_class($reply)
        ]);
    }
}

Let’s run that test again and see what happens.

vagrant@homestead:~/Code/forumio$ phpunit --filter test_an_authenticated_user_can_favorite_any_reply
PHPUnit 6.5.5 by Sebastian Bergmann and contributors.

E                                                                   1 / 1 (100%)

Time: 986 ms, Memory: 8.00MB

There was 1 error:

1) TestsFeatureFavoritesTest::test_an_authenticated_user_can_favorite_any_reply
IlluminateDatabaseQueryException: SQLSTATE[HY000]: General error: 1 no such table: favorites (SQL: insert into "favorites" ("user_id", "favorited_id", "favorited_type") values (, 1, AppReply))

Our code is trying to insert a record to a non existent table in the database. You know what that means. Yep, we need a new migration for this table.


Create A Migration For The Favorites Table

php artisan make migration

At this point, we are not actually going to run the migration ourselves as we are so used to doing. Remember, our environment is set up so that migrations run automatically every time we run a test. So I guess with that in mind, let’s run the test.

vagrant@homestead:~/Code/forumio$ phpunit --filter test_an_authenticated_user_can_favorite_any_reply
PHPUnit 6.5.5 by Sebastian Bergmann and contributors.

E                                                                   1 / 1 (100%)

Time: 850 ms, Memory: 8.00MB

There was 1 error:

1) TestsFeatureFavoritesTest::test_an_authenticated_user_can_favorite_any_reply
IlluminateDatabaseQueryException: SQLSTATE[HY000]: General error: 1 table favorites has no column named user_id (SQL: insert into "favorites" ("user_id", "favorited_id", "favorited_type") values (, 1, AppReply))

Ok. We are making progress. The favorites table is definitely in the database at this point, but the error is telling us that “table favorites has no column named user_id”. What is actually happening is there is no logged in user, so there is not user_id to insert into the database. We actually need to fix our test to reflect that.


public function test_an_authenticated_user_can_favorite_any_reply()
{
    $this->signIn();
    
    $reply = create('AppReply');

    $this->post('replies/' . $reply->id . '/favorites');

    $this->assertCount(1, $reply->favorites);
}

Once again, we need to trigger the test.

vagrant@homestead:~/Code/forumio$ phpunit --filter test_an_authenticated_user_can_favorite_any_reply
PHPUnit 6.5.5 by Sebastian Bergmann and contributors.

E                                                                   1 / 1 (100%)

Time: 1.27 seconds, Memory: 8.00MB

There was 1 error:

1) TestsFeatureFavoritesTest::test_an_authenticated_user_can_favorite_any_reply
UnexpectedValueException: The Response content must be a string or object implementing __toString(), "boolean" given.

Polymorphic One to Many (Morph Many) Relationship

Try saying that three times fast. This section introduces the polymorphic morphMany() relationship. Part of why our test is currently failing is because we do not yet have a favorites() method on the Reply Model. In this case, we are going to set up a Morph Many relationship. This is to express the idea that we can favorite different types of things. The morph many relation follows a specific convention. You pass in the name of the class relation as the first argument, and the string prefix of the “*_id” fields from the pivot table. So for example our pivot table has two fields of “favorited_id” and “favorited_type”. The string ‘favorited’ is the prefix, so that is what we use as the string for the second argument when setting up the morph many method.


<?php

namespace App;

use IlluminateDatabaseEloquentModel;

class Reply extends Model
{
    protected $guarded = [];

    public function owner()
    {
        return $this->belongsTo(User::class, 'user_id');
    }

    public function favorites()
    {
        return $this->morphMany(Favorite::class, 'favorited');
    }
}

That Favorite class does not yet exist, but let’s just confirm that by running the test.

vagrant@homestead:~/Code/forumio$ phpunit --filter test_an_authenticated_user_can_favorite_any_reply
PHPUnit 6.5.5 by Sebastian Bergmann and contributors.

E                                                                   1 / 1 (100%)

Time: 1.42 seconds, Memory: 12.00MB

There was 1 error:

1) TestsFeatureFavoritesTest::test_an_authenticated_user_can_favorite_any_reply
Error: Class 'AppFavorite' not found

This confirms what we expected to see. That is an easy fix, let’s go ahead and create that Favorite model.
generate new model class example

Run the test, and I’ll be a monkey’s uncle. We get green!

vagrant@homestead:~/Code/forumio$ phpunit --filter test_an_authenticated_user_can_favorite_any_reply
PHPUnit 6.5.5 by Sebastian Bergmann and contributors.

.                                                                   1 / 1 (100%)

Time: 1.55 seconds, Memory: 12.00MB

OK (1 test, 1 assertion)

Unauthenticated Users Should Not Be Able To Favorite

We should also add a test for guests that try to favorite a reply. We want to set up the app so that *only* authenticated users can favorite something. As such, we also need the inverse test of that scenario. What do we want to happen if a guest tries to favorite a reply? They should be redirected to the /login page. We know from our middleware tutorial that we can enable that feature like so in the controller.

<?php

namespace AppHttpControllers;

use AppReply;
use IlluminateHttpRequest;

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

    public function store(Reply $reply)
    {
        return DB::table('favorites')->insert([
            'user_id' => auth()->id(),
            'favorited_id' => $reply->id,
            'favorited_type' => get_class($reply)
        ]);
    }
}

Now we can set up a test like so:

public function test_a_guest_can_not_favorite_anything()
{
    $this->post('replies/1/favorites')
        ->assertRedirect('/login');
}

Running the test shows that it is working great.

vagrant@homestead:~/Code/forumio$ phpunit --filter test_a_guest_can_not_favorite_anything
PHPUnit 6.5.5 by Sebastian Bergmann and contributors.

.                                                                   1 / 1 (100%)

Time: 846 ms, Memory: 8.00MB

OK (1 test, 2 assertions)

Removing a DB Facade for a Dedicated Model

Earlier on we made use of the DB facade in the Favorites Controller inside the store() method. We now have a dedicated Favorite model class, so why don’t we use that instead? We can go back to that controller and update it like so.

<?php

namespace AppHttpControllers;

use AppFavorite;
use AppReply;
use IlluminateHttpRequest;

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

    public function store(Reply $reply)
    {
        Favorite::create([
            'user_id' => auth()->id(),
            'favorited_id' => $reply->id,
            'favorited_type' => get_class($reply)
        ]);
    }
}

Give that refactor a quick test to make sure all is well.

vagrant@homestead:~/Code/forumio$ phpunit --filter test_an_authenticated_user_can_favorite_any_replyPHPUnit 6.5.5 by Sebastian Bergmann and contributors.

E                                                                   1 / 1 (100%)

Time: 1.13 seconds, Memory: 8.00MB

There was 1 error:

1) TestsFeatureFavoritesTest::test_an_authenticated_user_can_favorite_any_reply
IlluminateDatabaseEloquentMassAssignmentException: user_id

Whoops. It seems we have a mass assignment exception. That is an easy fix. We can just fix the Favorite Model like so.

<?php

namespace App;

use IlluminateDatabaseEloquentModel;

class Favorite extends Model
{
    protected $guarded = [];
}

Rest assured, the test is now passing.


Model::create() vs method on Related model

Watch the one liner highlighted below replace the entire block of code that is commented out right after it.


<?php

namespace AppHttpControllers;

use AppFavorite;
use AppReply;
use IlluminateHttpRequest;

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

    public function store(Reply $reply)
    {
        $reply->favorites()->create(['user_id' => auth()->id()]);
//        Favorite::create([
//            'user_id' => auth()->id(),
//            'favorited_id' => $reply->id,
//            'favorited_type' => get_class($reply)
//        ]);
    }
}

Since there is a favorites() method on the Reply model, and it defines a morph many relationship, we can simply delegate to that method to create a new favorite in the database. Laravel does a bit of magic here and figures out everything it needs to insert correctly by simply providing the ‘user_id’. Pretty cool!

Now, to clean things up even more, we could simplify the code inside the store() method to just $reply->favorite().

public function store(Reply $reply)
{
    $reply->favorite();
}

We’ll need to put that code in a method on the Reply model now, like this:


<?php

namespace App;

use IlluminateDatabaseEloquentModel;

class Reply extends Model
{
    protected $guarded = [];

    public function owner()
    {
        return $this->belongsTo(User::class, 'user_id');
    }

    public function favorites()
    {
        return $this->morphMany(Favorite::class, 'favorited');
    }

    public function favorite()
    {
        $this->favorites()->create(['user_id' => auth()->id()]);
    }
}

It’s been a while since we ran the full suite of tests. Let’s run them all and see how we are doing.

vagrant@homestead:~/Code/forumio$ phpunit
PHPUnit 6.5.5 by Sebastian Bergmann and contributors.

........................                                          24 / 24 (100%)

Time: 2.92 seconds, Memory: 12.00MB

OK (24 tests, 41 assertions)

Sweet! It looks like everything is working, and we have added all the code to allow for an authenticated user to favorite a model.


Preventing Multiple Favorites By The Same User

Users of the application should not be able to apply a favorite to the same reply more than once. That needs to be protected against, so let’s add the code to support that here. As usual, we will start with a test. The pseudocode reads like so.

  • Given we have a signed in user
  • Given we have a Reply model
  • When the user tries to favorite more than once
  • Then there should be only 1 record in the database

Translating to code may yield this result.

public function test_an_authenticated_user_may_only_favorite_a_reply_one_time()
{
    $this->withoutExceptionHandling();
    $this->signIn();

    $reply = create('AppReply');

    $this->post('replies/' . $reply->id . '/favorites');
    $this->post('replies/' . $reply->id . '/favorites');

    $this->assertCount(1, $reply->favorites);
}
vagrant@homestead:~/Code/forumio$ phpunit --filter test_an_authenticated_user_may_only_favorite_a_reply_one_time
PHPUnit 6.5.5 by Sebastian Bergmann and contributors.

F                                                                   1 / 1 (100%)

Time: 890 ms, Memory: 8.00MB

There was 1 failure:

1) TestsFeatureFavoritesTest::test_an_authenticated_user_may_only_favorite_a_reply_one_time
Failed asserting that actual size 2 matches expected size 1.

/home/vagrant/Code/forumio/tests/Feature/FavoritesTest.php:42

FAILURES!
Tests: 1, Assertions: 1, Failures: 1.

This is currently failing since there is no protection to prevent multiple favorites. In the test, we make two calls to the favorite endpoint, and two records get inserted. We only want one record.


Protecting Against Multiple Favorites With a Unique Constraint

The first line of defense against this type of thing is to add a unique constraint to the database table in question. By adding the highlighted line of code below to the migration file for this table, we are adding a unique constraint that says the combination of user_id, favorited_id, and favorited_type must be unique upon inserting into the database.

<?php

use IlluminateSupportFacadesSchema;
use IlluminateDatabaseSchemaBlueprint;
use IlluminateDatabaseMigrationsMigration;

class CreateFavoritesTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('favorites', function (Blueprint $table) {
            $table->increments('id');
            $table->unsignedInteger('user_id');
            $table->unsignedInteger('favorited_id');
            $table->string('favorited_type', 50);
            $table->unique(['user_id', 'favorited_id', 'favorited_type']);
            $table->timestamps();
        });
    }

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

Running the test again now gives us a different error.

vagrant@homestead:~/Code/forumio$ phpunit --filter test_an_authenticated_user_may_only_favorite_a_reply_one_time
PHPUnit 6.5.5 by Sebastian Bergmann and contributors.

E                                                                   1 / 1 (100%)

Time: 900 ms, Memory: 10.00MB

There was 1 error:

1) TestsFeatureFavoritesTest::test_an_authenticated_user_may_only_favorite_a_reply_one_time
IlluminateDatabaseQueryException: SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: favorites.user_id, favorites.favorited_id, favorites.favorited_type (SQL: insert into "favorites" ("user_id", "favorited_id", "favorited_type", "updated_at", "created_at") values (1, 1, AppReply, 2018-01-19 23:15:46, 2018-01-19 23:15:46))

We now get an integrity constraint violation, which is actually a good thing in this case. The database is preventing a user from entering more than one of that combination of values to represent a favorite. So even if the request gets past the PHP level, the database is going to catch it and prevent it. With that being said, we still need to implement a way to make PHP prevent two favorites from even reaching the database. We will do this by updating the favorite() method on the Reply model like so.


public function favorite()
{
    $attributes = ['user_id' => auth()->id()];
    if (!$this->favorites()->where($attributes)->exists()) {
        return $this->favorites()->create($attributes);
    }
}

Now this method will fist check in the table to see if this favorite already exists. If it does, then the code inside the block never executes, and a second attempt to insert the same favorite is prevented. Perfect!

We can finish off this tutorial by running the full suite of tests, and feel relief that everything is in a passing state.

vagrant@homestead:~/Code/forumio$ phpunit
PHPUnit 6.5.5 by Sebastian Bergmann and contributors.

.........................                                         25 / 25 (100%)

Time: 3.11 seconds, Memory: 12.00MB

OK (25 tests, 42 assertions)

How To Favorite A Model Summary

You probably now know more about how to add a favorite to a model than you ever thought possible! We never even touched the browser, but we did make heavy use of tests to make sure we have the right logic in our code as well as in our database schema.

Click to share! ⬇️