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.
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.
<?php
namespace TestsFeature;
use IlluminateFoundationTestingDatabaseMigrations;
use TestsTestCase;
use IlluminateFoundationTestingWithFaker;
use IlluminateFoundationTestingRefreshDatabase;
class ActivityTest extends TestCase
{
use DatabaseMigrations;
public function test_it_records_activity_when_a_thread_is_created()
{
$this->signIn();
$thread = create('AppThread');
$this->assertDatabaseHas('activities', [
'type' => 'created_thread',
'user_id' => auth()->id(),
'subject_id' => $thread->id,
'subject_type' => 'AppThread'
]);
}
}
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) TestsFeatureActivityTest::test_it_records_activity_when_a_thread_is_created IlluminateDatabaseQueryException: 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" = AppThread))
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) TestsFeatureActivityTest::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.
<?php
namespace App;
use IlluminateDatabaseEloquentModel;
use TestsFeatureActivityTest;
class Thread extends Model
{
protected $guarded = [];
protected $with = ['creator', 'channel'];
protected static function boot()
{
parent::boot();
static::addGlobalScope('replyCount', function ($builder) {
$builder->withCount('replies');
});
static::deleting(function ($thread) {
$thread->replies()->delete();
});
static::created(function ($thread) {
Activity::create([
'type' => 'created_thread',
'user_id' => auth()->id(),
'subject_id' => $thread->id,
'subject_type' => 'AppThread'
]);
});
}
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) TestsFeatureActivityTest::test_it_records_activity_when_a_thread_is_created IlluminateDatabaseEloquentMassAssignmentException: type
Whoops! It looks like we are getting a mass assignment exception. Easy fix incoming. Disable mass assignment on the Activity Model like so:
<?php
namespace App;
use IlluminateDatabaseEloquentModel;
class Activity extends Model
{
protected $guarded = [];
}
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) TestsFeatureActivityTest::test_it_records_activity_when_a_thread_is_created IlluminateDatabaseQueryException: 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, AppThread, 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.
<?php
use IlluminateSupportFacadesSchema;
use IlluminateDatabaseSchemaBlueprint;
use IlluminateDatabaseMigrationsMigration;
class CreateActivitiesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('activities', function (Blueprint $table) {
$table->increments('id');
$table->unsignedInteger('user_id')->index();
$table->unsignedInteger('subject_id')->index();
$table->string('subject_type', 50);
$table->string('type');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('activities');
}
}
With the migration now in place, and all of the work done so far, we get our first passing test. Sweet!
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 ‘AppThread’. 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.
static::created(function ($thread) {
Activity::create([
'type' => 'created_' . strtolower((new ReflectionClass($thread))->getShortName()),
'user_id' => auth()->id(),
'subject_id' => $thread->id,
'subject_type' => get_class($thread)
]);
});
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:
protected function recordActivity($event)
{
Activity::create([
'type' => 'created_' . strtolower((new ReflectionClass($this))->getShortName()),
'user_id' => auth()->id(),
'subject_id' => $this->id,
'subject_type' => get_class($this)
]);
}
With that in place, the model event can be reduced down to this simple call.
static::created(function ($thread) {
$thread->recordActivity('created');
});
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.
<?php
namespace App;
use IlluminateDatabaseEloquentModel;
use TestsFeatureActivityTest;
class Thread extends Model
{
protected $guarded = [];
protected $with = ['creator', 'channel'];
protected static function boot()
{
parent::boot();
static::addGlobalScope('replyCount', function ($builder) {
$builder->withCount('replies');
});
static::deleting(function ($thread) {
$thread->replies()->delete();
});
static::created(function ($thread) {
$thread->recordActivity('created');
});
}
protected function recordActivity($event)
{
Activity::create([
'type' => $this->getActivityType($event),
'user_id' => auth()->id(),
'subject_id' => $this->id,
'subject_type' => get_class($this)
]);
}
protected function getActivityType($event)
{
return 'created_' . strtolower((new ReflectionClass($this))->getShortName());
}
public function path()
{
return '/threads/' . $this->channel->slug . '/' . $this->id;
}
public function replies()
{
return $this->hasMany(Reply::class);
}
public function creator()
{
return $this->belongsTo(User::class, 'user_id');
}
public function channel()
{
return $this->belongsTo(Channel::class);
}
public function addReply($reply)
{
$this->replies()->create($reply);
}
public function scopeFilter($query, $filters)
{
return $filters->apply($query);
}
}
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
<?php
namespace App;
trait RecordsActivity
{
}
Step 2: add use
statement to Thread Model
<?php
namespace App;
use IlluminateDatabaseEloquentModel;
use TestsFeatureActivityTest;
class Thread extends Model
{
use RecordsActivity;
protected $guarded = [];
Step 3: Refactor this: 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.
<?php
namespace App;
trait RecordsActivity
{
protected function recordActivity($event)
{
Activity::create([
'type' => $this->getActivityType($event),
'user_id' => auth()->id(),
'subject_id' => $this->id,
'subject_type' => get_class($this)
]);
}
protected function getActivityType($event)
{
return 'created_' . strtolower((new ReflectionClass($this))->getShortName());
}
}
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:
<?php
namespace App;
trait RecordsActivity
{
protected static function bootRecordsActivity()
{
static::created(function ($thread) {
$thread->recordActivity('created');
});
}
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.
protected function getActivityType($event)
{
$type = strtolower((new ReflectionClass($this))->getShortName());
return "{$event}_{$type}";
}
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.
protected function recordActivity($event)
{
$this->activity()->create([
'type' => $this->getActivityType($event),
'user_id' => auth()->id(),
]);
}
public function activity()
{
return $this->morphMany('AppActivity', 'subject');
}
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.
<?php
namespace TestsFeature;
use IlluminateFoundationTestingDatabaseMigrations;
use TestsTestCase;
use IlluminateFoundationTestingWithFaker;
use IlluminateFoundationTestingRefreshDatabase;
class ActivityTest extends TestCase
{
use DatabaseMigrations;
public function test_it_records_activity_when_a_thread_is_created()
{
$this->signIn();
$thread = create('AppThread');
$this->assertDatabaseHas('activities', [
'type' => 'created_thread',
'user_id' => auth()->id(),
'subject_id' => $thread->id,
'subject_type' => 'AppThread'
]);
$activity = Activity::first();
$this->assertEquals($activity->subject->id, $thread->id);
}
}
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:
<?php
namespace App;
use IlluminateDatabaseEloquentModel;
class Activity extends Model
{
protected $guarded = [];
public function subject()
{
return $this->morphTo();
}
}
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.
public function test_it_records_activity_when_a_reply_is_created()
{
$this->signIn();
$reply = create('AppReply');
$this->assertEquals(2, Activity::count());
}
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) TestsFeatureActivityTest::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.
<?php
namespace App;
use IlluminateDatabaseEloquentModel;
class Reply extends Model
{
use Favoriteable, RecordsActivity;
protected $guarded = [];
protected $with = ['owner', 'favorites'];
public function owner()
{
return $this->belongsTo(User::class, 'user_id');
}
}
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.
<?php
namespace App;
trait RecordsActivity
{
protected static function bootRecordsActivity()
{
foreach (static::getActivitiesToRecord() as $event) {
static::$event(function ($model) use ($event) {
$model->recordActivity($event);
});
}
}
protected static function getActivitiesToRecord()
{
return ['created'];
}
function recordActivity($event)
{
$this->activity()->create([
'type' => $this->getActivityType($event),
'user_id' => auth()->id(),
]);
}
public function activity()
{
return $this->morphMany('AppActivity', 'subject');
}
protected function getActivityType($event)
{
$type = strtolower((new ReflectionClass($this))->getShortName());
return "{$event}_{$type}";
}
}
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:
<?php
namespace App;
trait RecordsActivity
{
protected static function bootRecordsActivity()
{
if(auth()->guest()) return;
foreach (static::getActivitiesToRecord() as $event) {
static::$event(function ($model) use ($event) {
$model->recordActivity($event);
});
}
}
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.
public function test_authorized_users_can_delete_threads()
{
$this->withoutExceptionHandling()->signIn();
$thread = create('AppThread', ['user_id' => auth()->id()]);
$reply = create('AppReply', ['thread_id' => $thread->id]);
$response = $this->json('DELETE', $thread->path());
$response->assertStatus(204);
$this->assertDatabaseMissing('threads', ['id' => $thread->id]);
$this->assertDatabaseMissing('replies', ['id' => $reply->id]);
$this->assertDatabaseMissing('activities', [
'subject_id' => $thread->id,
'subject_type' => get_class($thread)
]);
}
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) TestsFeatureCreateThreadsTest::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
<?php
namespace App;
trait RecordsActivity
{
protected static function bootRecordsActivity()
{
if (auth()->guest()) return;
foreach (static::getActivitiesToRecord() as $event) {
static::$event(function ($model) use ($event) {
$model->recordActivity($event);
});
}
static::deleting(function ($model) {
$model->activity()->delete();
});
}
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.
<?php
namespace App;
use IlluminateDatabaseEloquentModel;
use TestsFeatureActivityTest;
class Thread extends Model
{
use RecordsActivity;
protected $guarded = [];
protected $with = ['creator', 'channel'];
protected static function boot()
{
parent::boot();
static::addGlobalScope('replyCount', function ($builder) {
$builder->withCount('replies');
});
static::deleting(function ($thread) {
$thread->replies->each->delete();
});
}
And now, we can run all the tests in the suite, and be proud that everything works!
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.