Sometimes we need to implement a view counter to keep track of how many times a post was viewed. Of course we can simply create a count variable and keep incrementing it with every GET request. But what if we consider logging recent views to keep track of the recent posts an user has viewed? In that case, a simple counter variable isn’t very helpful.


A simple yet flexible solution is to create a pivot between users and posts that stores how many times a post has been viewed by an user. We will create a separate entry for each user viewing a post. The overall procedure would be something like the following:

  • When an user views a post, dispatch a Job, e.g UserViewedPost , that will handle all the logic for logging the view.
  • Create a collection of the posts this user has viewed. If the collection contains the post currently being viewed, simply increment the view count. If the collection does not contain the current post, attach the current post to the collection.
  • To calculate total views of a post, sum up the number of times any user has viewed that particular post.


TL;DR: A complete implementation can be found on this Github repo 🙂

Setting up the migrations

Let’s create a pivot table user_post_views to store all the view related data.

public function up()
    {
        Schema::create('user_post_views', function (Blueprint $table) {
            $table->increments('id');
            $table->integer('user_id')->unsigned();
            $table->integer('post_id')->unsigned();
            $table->integer('count');
            $table->timestamps();

            $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
            $table->foreign('post_id')->references('id')->on('posts')->onDelete('cascade');
        });
    }

user_id will store the id of the user who is viewing the post. post_id will contain the id of the post currently being viewed. count is the number of times this user has viewed this post.

Setting up the relationship

Relationship between User and Post is simple – a user may view many posts and a post may be viewed by many users. Therefore, we setup a many to many relationship. Let’s define these relationships in the corresponding models.


Let’s start with User model:

public function viewedPosts()
{
    return $this->belongsToMany(Post::class, 'user_post_views')
                ->withTimestamps()
                ->withPivot(['count', 'id']);
}

The withTimestamps() method will ensure that the created_at and updated_at timestamps are automatically maintained at the pivot table. We are also grabbing the count and id fields from the pivot table because later we need to increment this.


Now let’s define the inverse relationship on our Post model:

public function viewedUsers()
{
    return $this->belongsToMany(User::class, 'user_post_views')
                ->withTimestamps()
                ->withPivot(['count']); 
}

Here also we are grabbing the count field from the pivot table. We will later use this to sum up the number of times a particular post has been viewed by different users.

Dispatching a job from the controller

Now that we have the relationship and pivot table setup between our models, let’s work on logging a view. Say we have this route to view a post:

Route::get('/posts/{post}', 'PostController@show')->name('posts.show');

Of course we can write the entire logging logic within the show() method on PostController , but a more flexible way would be to create a job and dispatch it every time a user views a post. In this way we not only separate the logging logic from the controller and keep it slim, but going forward we can even implement queuing on this.

So let’s create a job, say UserViewedPost . Now in the show() method, we can quickly dispatch() it before rendering the requested post.

public function show(Request $request, Post $post)
{
	if($request->user()) {
		dispatch(new UserViewedPost($request->user(), $post));
	}
	
	return view('posts.show', compact('post'));
}

We first ensure that a user is currently signed in. We then dispatch the UserViewedPost job and pass the currently signed in user and the post into the job’s constructor. Finally we render a view with that post.

Logging recent views

Now that we are dispatching UserViewedPost job from the PostController , let’s log the view.


First let’s accept the models that were passed into the job’s constructor.

public $user;
public $post;

public function __construct(User $user, Post $post)
{
    $this->user = $user;
    $this->post = $post;
}

We are keeping the $user and $post properties as public, because we may want to queue this job later.

Next, in the handle() method, we first want to check if this user has already viewed this post. In case he has already viewed it, we do not need to create a new entry as we can simply increment the count from the previous entry.

// this user has already viewed this post
if($this->user->viewedPosts->contains($this->post)) {

    $this->user->viewedPosts->where('id', $this->post->id)
                            ->first()
                            ->pivot
                            ->increment('count');

    return;
}

$this->user->viewedPosts is used to access the relationship between User and Post . This will return a collection of posts this user has viewed. The contains($this->post) will determine if the collection already contains this post. If it does, we search for this post where('id', $this->post->id) in the collection, grab the first() entry, then access the pivot and increment the count .


However, if this user has not viewed this post earlier, we want to create a new record for this in our pivot table.

// this user is viewing this post for the first time 
$this->user->viewedPosts()->attach($this->post, [
    'count' => 1
]);

Because we are working with many to many relationship, we can simply access this user’s viewed posts $this->user->viewedPosts() and attach() this post to this user. We also want to initialize our view count. The attach() method also takes an array of additional data, so we can pass the initial count into this method attach($this->post, ['count' => 1]).


That’s all we need to log views. The complete handle() method is as follows:

public function handle()
{
    // this user has already viewed this post
    if($this->user->viewedPosts->contains($this->post)) {
        $this->user->viewedPosts->where('id', $this->post->id)
                                ->first()
                                ->pivot
                                ->increment('count');

        return;
    }

    // this user is viewing this post for the first time 
    $this->user->viewedPosts()->attach($this->post, [
        'count' => 1
    ]);
}

The handle() method is called whenever the job is processed. We can also queue this job. Laravel documentation provides a detailed explanation of setting up queues.

Fetching the recently viewed posts

Now that the views are being logged, we want to show the users a list of their recently viewed posts.


Say we have a route like this:

Route::get('/posts/recent', 'PostController@recentlyViewedPosts')
							->name('posts.recent')
							->middleware('auth');

Because we have the necessary relationship setup, fetching these records are very easy.

public function recentlyViewedPosts(Request $request)
    {
        $posts = $request->user()->viewedPosts()
                                ->orderBy('pivot_updated_at', 'desc')
                                ->take(5)
                                ->get();

        return view('posts.recent', [
            'posts' => $posts
        ]);
    }

We get the request object and from that request, we extract the currently signed in user and accessing the relationship, we query for all the posts this user has viewed, $request->user()->viewedPosts() , sort the result by updated_at field and take() the last 5 posts. In the orderBy() method, we are using pivot_updated_at to sort the posts by using the time they were last viewed. If we just use updated_at instead, it will use the time when the posts were last edited and not the time when they were inserted or incremented in the pivot, which we do not want. Lastly, we go ahead and render a view.


Notice that we are hardcoding the number of posts in the take() method. If you want to mention the number of posts you are showing in your view, you have to again hardcode it. This is usually not a good practice. A better way is to create a limiting constant, say const POST_LIMIT = 5; and then use it like this: take(self::POST_LIMIT) . You can then pass this constant to your views and use it. This way refactoring becomes easy.

Calculating total views of a post

To calculate the total views of a post, we will use the reverse relationship we setup in our Post model.

public function views()
{
	return array_sum($this->viewedUsers->pluck('pivot.count')->toArray());
}

Remember we have set this as a many to many relationship. That means, each post has many users who may have viewed it. Therefore, using the reverse relationship, we create a collection of users $this->viewedUsers who have viewed this post. Using the pluck() method, we then pluck out the count from the pivot table , which gives us a collection of the number of times any user has viewed this particular post. The toArray() method then converts the collection to an array and we use array_sum() to sum up all the values.


Now in our views, we can use {{ $post->views() }} to show the number of times a post has been viewed.

Conclusion

There are a couple of things we have not addressed here:

  • First, the view counter only works for signed in users. If an user is not signed in, but viewing a post, that view will not be added to the total number of views. Of course you can go ahead and tweak this code to increment the count regardless of the user is signed in or not.
  • Second, we have not implemented view throttling. What we are doing here is that if a signed in user keeps loading a post, the view count will keep on increasing. You may want to limit this count hourly or daily, i.e. if a user views a post multiple times within an hour, view count will be incremented only once. An excellent blog post on view throttling, written by Stidges, can be found here.


Happy coding…

ヽ(´▽`)/