Implementing a page view counter into your Laravel application seems like the easiest job at first. Just add a column in your database with the current view count and increment that on every page load, right? Well, there's a little more to it: This will cause the counter to increment on every page load, even when the user refreshes the page or subsequently visits the page in a short amount of time. This usually isn't the desired behavior, so let's look into solving this issue. In particular, we will be making use of Events and Route Filters to achieve this. Let's walk through the process of creating this using an incremental approach.
Most of the code in this post is taken from the Laravel-Tricks repository which I have developed together with Maks Surguy. I have written this post to give background information about the development process of this project.
Preparing The Database
First, let's set up a database to work with. We will create a simple posts table migration using the following command:
php artisan migrate:make create_posts_table --create="posts"
Note: If you are still running Laravel 4.0, you can achieve the same effect using the following command:
php artisan migrate:make create_posts_table --create --table="posts"
Laravel now generates an empty migration file for you, which can be found in the app/database/migrations/ directory. Let's keep it really simple and only add a few fields to it:
// From app/databases/migrations/0000_00_00_000000_create_pages_table.php
public function up()
{
Schema::create('posts', function (Blueprint $table)
{
$table->increments('id');
$table->string('title');
$table->text('content');
$table->integer('view_count')->default(0);
$table->timestamps();
});
}
Next, we can migrate the table by running the following command:
php artisan migrate
Okay great, now that we have something to work with, let's dig into the issue at hand.
Preparing The Application
We are going to need a simple controller class, a post model and a route that leads to the controller. I am going to leave the views up to you as that is besides the scope of this post. I am going to namespace my classes (except the controllers), you may do the same if you wish to.
First let's set up a very simple model:
// from app/Stidges/Post.php
namespace Stidges;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
protected $table = 'posts';
// As a best practice, always set up the fillable property on your model!
protected $fillable = [ 'title', 'content' ];
}
Next, let's be quick and generate a controller using Artisan:
php artisan controller:make PostsController
We won't be using any of the methods besides the 'show' method in this post, but you will probably want to set up most of these.
All that's left now is to set up a route that points to our controller, and we will be ready to dig in!
// From app/routes.php
Route::resource('posts', 'PostsController');
Incrementing The Post View Counter
To increment our post view counter, we will be making use of Laravel's Event system. This gives us a way of firing certain actions when something happens in your application. You might have already used these when working with Eloquent. Eloquent fires events when a model is creating, when it is created, when it is updating, etc. The Event system in Laravel provides a nice way to hook into these events and allows us to, for example, validate our models before they are created or updated.
So how does this apply to the issue at hand? Well, we will create an event that gets fired on every load of a post. Then, in some other part of the application, we can hook into that event and increment our view counter. This allows us to extract this kind of logic out of the controller and into a dedicated class, keeping our controllers clean and free of non-request related logic.
Let's set up our controller's 'show' action first:
// From app/controllers/PostsController.php
use Stidges\Post;
// ...
public function show($id)
{
// We will just be quick here and fetch the post
// using the Post model.
$post = Post::find($id);
// Next, we will fire off an event and pass along
// the post as its payload
Event::fire('posts.view', $post);
// Finally, return some view that displays our post
// and pass along the post model
return View::make('posts.show')->withPost($post);
}
Okay, our show method is in place. Now where should we put the logic related to the event we just fired? Laravel allows us to register a class as an event listener. By default, it will call a 'handle' method on this class.
After registering the class as an event listener for the specific event we just created, the 'handle' method will be called whenever that event is fired. We will be creating a dedicated class for handling the view counter on the post and place it within our namespace. Using a class to handle an event makes it more testable and allows us to utilize dependency injection. Read more about using a class as an event listener here.
// from app/Stidges/Events/ViewPostHandler.php
namespace Stidges\Events;
use Post;
class ViewPostHandler
{
public function handle(Post $post)
{
// Increment the view counter by one...
$post->increment('view_counter');
// Then increment the value on the model so that we can
// display it. This is because the increment method
// doesn't increment the value on the model.
$post->view_counter += 1;
}
}
Next we will subscribe our class to the event. There are a couple of options to place Event subscriptions, like using a ServiceProvider and placing the subscriptions in its boot() method. For this post though, we will just place it in the global start file. Add the following to the very bottom:
// From app/start/global.php
Event::subscribe('posts.view', 'Stidges\Events\ViewPostHandler');
Cool! Now any time a post is shown, the 'posts.view' event is fired and we have set up a class to listen for that event and increment the view counter! Done! ... But wait, in the beginning of this post we said this wasn't what we were after! Now the view counter is ALWAYS incremented. How can we prevent this from happening?
Using The Session To Throttle The View Counter
We will be using the session to hold our recently viewed posts. By storing the post id in the session so that we can reference it from our event handler class. To do this, we must first inject the Laravel Session Store into our handler class:
// From app/Stidges/Events/ViewPostHandler.php
use Illuminate\Session\Store;
class ViewPostHandler
{
private $session;
public function __construct(Store $session)
{
// Let Laravel inject the session Store instance,
// and assign it to our $session variable.
$this->session = $session;
}
// ...
}
Now that we have the session instance in our event handler class, we can check the session for the post id. If it already exists in the session, don't increment the view counter. If it does, increment the view counter and store the post id in the session.
// From app/Stidges/Events/ViewPostHandler.php
public function handle(Post $post)
{
if ( ! $this->isPostViewed($post))
{
$post->increment('view_counter');
$post->view_counter += 1;
$this->storePost($post);
}
}
private function isPostViewed($post)
{
// Get all the viewed posts from the session. If no
// entry in the session exists, default to an
// empty array.
$viewed = $this->session->get('viewed_posts', []);
// Check the viewed posts array for the existance
// of the id of the post
return in_array($post->id, $viewed);
}
private function storePost($post)
{
// Push the post id onto the viewed_posts session array.
$this->session->push('viewed_posts', $post->id);
}
With this code in place, the view counter will only increment once per user session. The problem with this is that you may use a long session time, or even a never expiring one, which may result in only a single view increment. What if the user comes back in an hour or the next day? We may want to increment the counter then. This means we have to set up some way of cleaning up the viewed posts session entry after a given time expires.
Letting The Session Entry Expire After A Given Time
To tackle this problem, we will make use of route filters. We want to call a filter on every page load that checks whether some viewed posts can be cleared from the session. Again, we will be using a dedicated class for handling this filter. This allows us to inject the session instance into the class and use it to do our cleaning. By default, when assigning a class to handle a filter, Laravel will call the 'filter' method on this class. Read more about using route filters here.
Let's set up the class to handle this:
// From app/Stidges/Filters/ViewThrottleFilter.php
use Illuminate\Session\Store;
class ViewThrottleFilter
{
private $session;
public function __construct(Store $session)
{
// Let Laravel inject the session Store instance,
// and assign it to our $session variable.
$this->session = $session;
}
public function filter()
{
$posts = $this->getViewedPosts();
if ( ! is_null($posts))
{
$posts = $this->cleanExpiredViews($posts);
$this->storePosts($posts);
}
}
private function getViewedPosts()
{
// Get all the viewed posts from the session. If no
// entry in the session exists, default to null.
return $this->session->get('viewed_posts', null);
}
private function cleanExpiredViews($posts)
{
// ...
}
private function storePosts($posts)
{
$this->session->put('viewed_posts', $posts);
}
}
As you can see, I left open the cleanExpiredViews() function. I have done this because we have to make some changes to our event class first. We need some way to keep track of when a post was viewed. To achieve this, let's store a timestamp along with the post id so that we can work off of that:
// From app/Stidges/Events/ViewPostHandler.php
// ...
private function isPostViewed($post)
{
$viewed = $this->session->get('viewed_posts', []);
// Check if the post id exists as a key in the array.
return array_key_exists($post->id, $viewed);
}
private function storePost($post)
{
// First make a key that we can use to store the timestamp
// in the session. Laravel allows us to use a nested key
// so that we can set the post id key on the viewed_posts
// array.
$key = 'viewed_posts.' . $post->id;
// Then set that key on the session and set its value
// to the current timestamp.
$this->session->put($key, time());
}
In the storePost() I use a nested key. When doing this, the viewed_posts array on the session will look like this:
'viewed_posts' => [ '<post id>' => '<time>', ... ]
If the post id did not exist on the session yet, it will create it. If it did exist, it will change it to the new timestamp.
Now that we have a timestamp at which the post was viewed, we can complete our filter class to use that timestamp. To clean the session from expired views, we will be using PHP's array_filter function. This allows us to loop over the array and only keep posts which have a timestamp that hasn't expired yet. The closure inside the array_filter function is expected to return a boolean. If true is returned, the value is kept in the array. If false is returned, the value is removed from the array.
Let's go back to the filter class and fill in the missing pieces:
// From app/Stidges/Filters/ViewThrottleFilter.php
// ...
private function cleanExpiredViews($posts)
{
$time = time();
// Let the views expire after one hour.
$throttleTime = 3600;
// Filter through the post array. The argument passed to the
// function will be the value from the array, which is the
// timestamp in our case.
return array_filter($posts, function ($timestamp) use ($time, $throttleTime)
{
// If the view timestamp + the throttle time is
// still after the current timestamp the view
// has not expired yet, so we want to keep it.
return ($timestamp + $throttleTime) > $time;
});
}
Wrapping Up
We have set up everything that's necessary to use the view counter in our application! All that is left is registering the filter to be called on every page load. Add the following to the top of your routes file:
// From app/routes.php
// Call the 'posts.view_throttle' filter on every page.
Route::when('*', 'posts.view_throttle');
Finally we have to register our dedicated class to this filter key. Add the following to the bottom of the filter file:
// From app/filters.php
// Register the dedicated class as the handler
// of the 'posts.view_throttle' filter.
Route::filter('posts.view_throttle', 'Stidges\Filters\ViewThrottleFilter');
And that's it! Now when a user views your post, the counter will increment by one. If he / she views it again or refreshes the page, the counter will not increment. Then, when the user comes back after an hour and views your post, the counter will increment again!