|

Record User Activity To The Database

Record User Activity To The Database

Activity feeds are a really cool feature you see in a lot of different websites. The idea is there is a timeline so to speak of things a user has done in the application, and then that user or other users can look at the historical record of various actions taken. In this little forum application, we can set up a new activity when a user creates a new thread. We could also set up a new activity for when a user creates a new reply to a thread. The first step in getting an activity feed working, is correctly storing these actions into the database as they happen. We will make use of model events and a new Activity class to help us create this feature.


Add A New ActivityTest Class

Go ahead and create a new test class called ActivityTest and place it in the Unit test folder. Here we create the new file and stub out the testing boilerplate.
test class boilerplate code

Now we are going to modify the test method to be test_it_records_activity_when_a_thread_is_created(). What this test is going to confirm is that when a user creates a thread, then at the same time an activity will also be generated and inserted into the database. Here is the first stab at that goal.

Now just to get us started, let’s run the test. We know it will fail, but the errors will drive us to take each next step.

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

E                                                                   1 / 1 (100%)

Time: 796 ms, Memory: 8.00MB

There was 1 error:

1) Tests\Feature\ActivityTest::test_it_records_activity_when_a_thread_is_created
Illuminate\Database\QueryException: SQLSTATE[HY000]: General error: 1 no such table: activities (SQL: select count(*) as aggregate from "activities" where ("type" = created_thread and "user_id" = 1 and "subject_id" = 1 and "subject_type" = App\Thread))

Right off the bat we get the error that the table we are trying to work with in the database does not exist. Let’s fix that.


Create Activity Model and Migration

Using artisan we can make a model for Activity and generate a migration for us at the same time.

vagrant@homestead:~/Code/forumio$ php artisan make:model Activity -m
Model created successfully.
Created Migration: 2018_01_30_164752_create_activities_table

The table is now created, so that first error should be fixed. Run the test again, and see what comes next.

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

F                                                                   1 / 1 (100%)

Time: 2.44 seconds, Memory: 8.00MB

There was 1 failure:

1) Tests\Feature\ActivityTest::test_it_records_activity_when_a_thread_is_created
Failed asserting that a row in the table [activities] matches the attributes {
    "type": "created_thread",
    "user_id": 1,
    "subject_id": 1,
    "subject_type": "App\\Thread"
}.

The table is empty.

/home/vagrant/Code/forumio/vendor/laravel/framework/src/Illuminate/Foundation/Testing/Concerns/InteractsWithDatabase.php:22
/home/vagrant/Code/forumio/tests/Unit/ActivityTest.php:24

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

Ok the table now exists, but it is not getting any data inserted during a thread creation. We need to build out that function now.


Using Model Events in Thread.php

This is a perfect use case for model events. In other words, we set up listeners for when a Thread model is executing, and then take an action whenever that Model triggers. We can start by using the model events within the Thread model. We’ll add another model event in the boot() method like we have seen in a prior tutorial. This highlighted code below is saying that whenever a Thread is created in the database, immediately also create an Activity in the database.

Great! Looks good, so we can run the test again and see how we are doing.

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

E                                                                   1 / 1 (100%)

Time: 1.13 seconds, Memory: 8.00MB

There was 1 error:

1) Tests\Feature\ActivityTest::test_it_records_activity_when_a_thread_is_created
Illuminate\Database\Eloquent\MassAssignmentException: type

Whoops! It looks like we are getting a mass assignment exception. Easy fix incoming. Disable mass assignment on the Activity Model like so:

Run the test again and notice that we are still getting errors.

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

E                                                                   1 / 1 (100%)

Time: 798 ms, Memory: 8.00MB

There was 1 error:

1) Tests\Feature\ActivityTest::test_it_records_activity_when_a_thread_is_created
Illuminate\Database\QueryException: SQLSTATE[HY000]: General error: 1 table activities has no column named type (SQL: insert into "activities" ("type", "user_id", "subject_id", "subject_type", "updated_at", "created_at") values (created_thread, 1, 1, App\Thread, 2018-01-30 17:00:35, 2018-01-30 17:00:35))

Update Activity Migration

What have we not done yet? That’s right, we have not defined the actual table fields in the migration file. So when the migrations run during the running of a test, the table gets created but there are no fields whatsoever so things are not going to work. No problem, lets build up the migration file now. We need include database fields for the user_id, subject_id, subject_type, and type.

With the migration now in place, and all of the work done so far, we get our first passing test. Sweet!
activity test passing


Refactoring Thread

At the most basic of levels, we have the recording of new activity into the database working. It would be nice to make that code more general, so that not only can threads be recorded as an activity, but also replies as an activity. Let’s see how we might approach this. The first thing we notice is that right now they subject_type is hard coded to ‘App\Thread’. How about we swap that out for get_class($thread). We can also swap out created_thread for ‘created_’ . strtolower((new \ReflectionClass($thread))->getShortName()). This will use reflection to look at the class being used to insert the activity, and populate the database accordingly.

It also appears that the test for this feature is still working, so that is nice.

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

.                                                                   1 / 1 (100%)

Time: 1.14 seconds, Memory: 8.00MB

OK (1 test, 1 assertion)

Extract A Method To The Object

Now what we can do is extract the logic in the model event to a protected method to make the code just a little more clear. We can add a protected function named recordActivity() to the class like so:

With that in place, the model event can be reduced down to this simple call.

After adding one final refactor we now have two protected methods and one model event related to recording activity. Those sections of code are highlighted here.


Refactoring To A Trait

It often makes sense to move reusable code right into a trait if you like. Then you can use that code in any class you like by simply making use of a use statement in the particular class. Let’s see what we mean.


Step 1: Create a New Trait File

create new trait class


Step 2: add use statement to Thread Model


Step 3: Refactor this: Pull Members Up

refactor pull members up

Once those three steps are complete, you will now have a Trait file that contains the code below, and the two methods we “pulled up” are no longer a part of the Thread Model class. Pretty cool.

Just to be sure all this refactoring is still going good, we can run the test again, and it does look good.

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

.                                                                   1 / 1 (100%)

Time: 794 ms, Memory: 8.00MB

OK (1 test, 1 assertion)

Refactor A Model Event

Right now we still have that model event on the Thread class. PHP Storm is unable to refactor this next bit of code as it is kind of a Laravel specific trick. We are going to take the model event from the Thread class, and move it into a protected function on the Trait named boot(). Let’s see how. In the Trait, we can add this code here:

The convention is to name the function beginning with ‘boot’ followed by the Trait name. So boot plus RecordsActivity results in bootRecordsActivity() in our case. Laravel is able to detect this convention and trigger the model event the same as if it was created on the original Model itself. What is really interesting here is that the Thread Model is now back to where it was at the beginning of this tutorial. The only difference is that it now has that one line of use RecordsActivity; which basically turns on Activity recording for the Thread Model. Slick!


Refactoring ActivityTest

You thought we were done. We are not! We can make the Trait itself cleaner as well. Here is a cleaner version of the getActivityType() method.


Polymorphic Refactor

Now we will complete a refactor to make use of a Polymorphic Many relationship. We use the morphMany() method to set this up. By also providing the prefix of ‘subject’, Laravel is able to dynamically figure out the correct subject_id and subject_type for each activity.


Further Clarifying The Test Case

The addition to the test below states that the subject relationship should be valid. In other words, if there is an Activity Model, and you request the subject of this activity, it should be the thread.


Adding The morphTo() method

The test above will not work just yet, because we are trying to use a relationship method that does not yet exist, i.e., subject(). That method needs to be added to the Activity Model like so:


Test For Recording Reply Activities

Not only do we want an activity to be recorded if a user creates a thread, but what about when they create a reply? Surely we want to include replies as part of the activity feed so let’s set that up as well.

First we have the test. When a reply is created with the model factory, a base thread is also created. So what does that mean? That means there should be 2 activity records in the database. This test markup reflects that.

Ok cool. We can run the test then.

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

F                                                                   1 / 1 (100%)

Time: 801 ms, Memory: 8.00MB

There was 1 failure:

1) Tests\Feature\ActivityTest::test_it_records_activity_when_a_reply_is_created
Failed asserting that 1 matches expected 2.

/home/vagrant/Code/forumio/tests/Unit/ActivityTest.php:38

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

Well that is giving us a big fat failure. We aren’t actually generating activity records for replies yet. Oh man, does that mean we have to now build out the process of recording activities all over again?! Well guess what. Since you used a Trait, and since that Trait leverages Polymorphic relationships, you are going to be amazed at how easy it is to start recording activities on replies. Add one word to your Reply Model and you are done. Check it out.

All we did here is to simply tell the Model to use RecordsActivity. With that one simple change, the test passes! We’ll also add favorites to the activity feed with this trait soon.

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

.                                                                   1 / 1 (100%)

Time: 779 ms, Memory: 8.00MB

OK (1 test, 1 assertion)

Adding a getActivitiesToRecord() method

In order to add the ability to track some events but not others on your models, we can add a new method to handle this. Once the new method is in place, the bootRecordsActivity() method can also be updated to reflect this change.


Finishing up by running all tests.

It looks like we hit a snag when running all tests now.

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

.....E........EEEEEEE..E.EEEEEE                                   31 / 31 (100%)

Time: 3.69 seconds, Memory: 14.00MB

There were 15 errors:

Fiftenn errors. Ouch. In the RecordsActivity Trait, we are just recording activities with no regard for whether a user is logged in or not. Well, the only time an activity should be recorded is if a user is logged in. So we can update that Trait like so:

Now all tests do pass.


Deleting Related Activites

The final thing we need to cover is if a user deletes a thread, does that also delete any associated activity? Right now that does not happen, but we will add code to make sure this works. First we are going to update the test_authorized_users_can_delete_threads() test in the CreateThreadsTest class.

Run the test now that we have modified it:

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

F                                                                   1 / 1 (100%)

Time: 887 ms, Memory: 10.00MB

There was 1 failure:

1) Tests\Feature\CreateThreadsTest::test_authorized_users_can_delete_threads
Failed asserting that a row in the table [activities] does not match the attributes {
    "subject_id": 1,
    "subject_type": "App\\Thread"
}.

Found: [
    {
        "id": "1",
        "user_id": "1",
        "subject_id": "1",
        "subject_type": "App\\Thread",
        "type": "created_thread",
        "created_at": "2018-01-30 21:15:04",
        "updated_at": "2018-01-30 21:15:04"
    },
    {
        "id": "2",
        "user_id": "1",
        "subject_id": "1",
        "subject_type": "App\\Reply",
        "type": "created_reply",
        "created_at": "2018-01-30 21:15:04",
        "updated_at": "2018-01-30 21:15:04"
    }
].

/home/vagrant/Code/forumio/vendor/laravel/framework/src/Illuminate/Foundation/Testing/Concerns/InteractsWithDatabase.php:42
/home/vagrant/Code/forumio/tests/Feature/CreateThreadsTest.php:80

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

Let’s make it pass!


Add a deleting event listener in RecordsActivity

The first thing we’ll do is to set up a new event listener in the RecordsActivity Trait


Update the deleting event listener in the Thread Model

The above handles making sure an activity for a thread gets deleted. Now we must also make sure that the activity for a reply is also deleted. We can use higher order messaging in Laravel Collections to help.

And now, we can run all the tests in the suite, and be proud that everything works!
phpunit testing activity feed feature


Record User Activity To The Database Summary

Great work if you made it all the way through this tutorial. We covered a ton of features you can use when setting up the backend side of an activity feed feature for your application. We were able to see how a Trait could be used along with model events to track user activity in the database for further use.

|