STAFF BLOG

Laravel 5.2 Events and Listeners for points and badges gamification system (Part 1: Points)

Habi*doはいろんなゲーミフィケーションの要素を積極的に取り入れ、より楽しく、シンプルに使えるエンゲージメントツールです。

次はその中のポイントとバッジを例にして、近年爆発的な人気を誇るlaravel(PHPのフレームワーク)のイベント(Even)とリスナー(Listener)を使って、ゲーミフィケーションの要素を実現する方法を簡単に紹介したいと思います。

実際の計算方法はもっと複雑で、独自のアルゴリズムを使っていますが、仕組みだけを説明します。

This is “Part 1: Points” covering how to implement a simple Points system with Events and Listeners.

In “Part 2: Badges” (coming soon) we will show the implementation of a little more complex Badge system using Events and Listeners.

System Overview

Base system
Simple gamification system that we want to create
Laravel side system explained
Prerequisite

Setup

Tables

Users Table
Posts Table
Likes Table
Points Table

Models

Model for Users (app/Model/User.php)
Model for Posts (app/Model/Posts/Post.php)
Model for Likes (app/Models/Likes/Like.php)
Model for Points (apps/Models/Points/Point.php)

Events

LikeClickedEvent (app/Events/LikeClickedEvent.php)
ListenersLikePointsListener (app/Listeners/PointsListeners/LikePointsListener.php)

EventServiceProvider

Controllers

NewsfeedController (app/Http/Controllers/Newsfeed/NewsfeedController.php)

 

System Overview

Base system

Let’s just have a simple system similar to Facebook that everyone understands:
Users can create Posts that have a “like” button.
Other users can see these Posts and click the “like” button.

Simple gamification system that we want to create

A user can get points and badges.
If a user clicks the like button we want the following to happen:

  • User A, who clicked “like” receives 1 points to encourage further engagement in the system (Positive reinforcer).
  • User B, whose post was liked receives 10 points to encourage delivering further content (Positive reinforcer).
  • User B, whose post was liked receives a Badge if this this was the 100th time that User B received a “like” on any of his posts (Jackpot Positive reinforcer).

Laravel side system explained

laravel-bages-and-points-setup

    1. User A clicks “like” on User B's post, which is routed to the a Controller in Laravel
    2. The Controller handles the like button click (this is not covered here)
    3. Before returning a response to User A, the Controller creates an event, which we will call “LikeClickedEvent”-event.
    4. In the EventServiceProvider (app/Providers/EventServiceProvider),
      we will set two listeners to listen for the LikeClickedEvent event.
      We will call these two Listeners “LikePointsListener” and “LikeBadgeListener”.
      In other words: Whenever the “LikeClickedEvent” event is called anywhere,
      the EventServiceProvider will trigger the “LikePointsListener” and “LikeBadgeListener”
      to run in a queue so that the Controller can return a response to the user
      without waiting for the server to do the Points and Badge calculations.
    5. The Controller returns a response to the user
    6. The “LikePointsListener” and “LikeBadgeListener” run server side in the queue when the server has time.

Prerequisite

 

Setup

Tables

For this entry we have stripped the tables to the bare minimum columns. You will probably have further columns and pivot tables.

Users Table

(※You should get this from the Auth scaffold)


Schema::create('users', function (Blueprint $table) {
        $table->increments('id');
        $table->string('email')->index(); //used as username
        $table->string('password');
        $table->timestamps(); 
        $table->softDeletes(); //optional but recommended
});

 

Posts Table

(※only columns for the Points/Badge functionality are included here. For actual post functionality, you would want to add a post “content” column etc.)

Each post belongs to a user, so we include a user_id column as foreign key.


Schema::create('posts', function (Blueprint $table) {
        $table->increments('id');
        $table->integer('user_id')->unsigned()->index() //user who posted 
        $table->timestamps(); 
        $table->gt;softDeletes(); //optional but recommended

        //set foreign key relations
        $table->foreign('user_id')
              ->references('id')
              ->on('users')
              ->onDelete('cascade'); //If user is deleted, also delete the user Posts.
});

 

Likes Table

“likes” are given by users on various items such as posts in our example. We use a morph here instead of a “post_id” column so that we can keep the system flexible in adding other things that can be liked in the future.  In this case we call this moph ‘target’ which will give us two columns: “target_type” and “target_id”. Target_type can be a post, but also something else if you wish to add other “likable” things to the system.

(※Read more about Polymorphic relations in the Laravel Documentation:
https://laravel.com/docs/5.2/eloquent-relationships#polymorphic-relations [last accessed 2016/11/14])

Each like was given by a user for which we use the user_id column, and we do not want a user to like the same thing several times so we make the combination of user_id, target_id, and target_type unique.


Schema::create('likes', function (Blueprint $table) {
       $table->increments('id');
       $table->integer('user_id')->unsigned()->index(); //user id of user giving like
       $table->morphs('target'); //morph to ”posts” and other things that can be liked
       $table->timestamps();
       $table->softDeletes(); //optional but recommended
 
       //set foreign key relations
       $table->foreign('user_id')
             ->references('id')
             ->on('users')
             ->onDelete('cascade'); //If user is deleted, also delete the likes this user gave.
 
       //make unique combination so a user can only like a post/etc. Once.
       //only add deleted_at if you are using softDeletes
       $table->unique(['user_id','target_id','target_type','deleted_at']);
});

 

Points Table

The points table will contain all points ever allocated. Instead of just added point to a points column in the users table, this will allow us to undo given points. But more so, this will allow us a wide range of analysis: points gotten for what, by whom, at what time, how long after posting, etc. Such data is extremely valuable if you want to further enhance gamification through user behaviour analysis.
This table also uses morph as you will probably want to include getting points from other things other than like buttons.

 


Schema::create('points', function (Blueprint $table) {
       $table->increments('id');
       $table->integer('user_id')->unsigned()->index(); //user id of user getting points
       $table->morphs('origin'); //morph to ”likes” and other actions that give points 
       $table->integer('points_added')->unsigned(); //amount of points added
       $table->enum('points_genre', ['self', 'other']); //type self if from action that the user did. type other if from other users action
       $table->timestamps();
       $table->softDeletes(); //optional but recommended
 
       //set foreign key relations
       $table->foreign('user_id')
             ->references('id')
             ->on('users')
             ->onDelete('cascade'); //If user is deleted, also delete users points
});

 

Models

We usually have a folder inside the Model folder for each “feature” or specific Model as when the system gets more complex it is easy to put related traits in that folder as well.
We have removed things such as transactions to allow for a shorter and easier to read code.
Other Notes: we use constants for all column names in order to avoid mistyping, readability (differentiation to strings,  and for maintenance.

 

Model for Users (app/Model/User.php)

namespace App\Model;

use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Model\Points\Point;
use App\Model\Likes\Like;
use App\Model\Posts\Post;
use Auth;

class User extends Authenticatable
{
 
    use SoftDeletes; 
    
    const COL_EMAIL = 'email';
    const COL_ID = 'id';
    const COL_PASSWORD = 'password';
    
    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = [
        self::COL_EMAIL
    ];

    /**
     * The attributes excluded
     *
     * @var array
     */
    protected $hidden = [
        self::COL_PASSWORD
    ];
    
    
    /**
     * Cascade Soft Delete.
     * Else if you delete a user, 
     * it will just fully (hard) Delete the related children such as Likes
     */
    protected static function boot ()
    {
        parent::boot();
        
        self::deleting(function (User $user) {
        
            //delete likes the user has given
            $user->likes->delete();
            
            //delete posts the user has written 
            $user->posts->delete()
            
            //delete all points the user got
            $user->points->delete();
            
        });
    }    
    
    
    /**
     * Relationship to Points.
     * A user can have many points
     */
    public function points(){
        return $this->hasMany(Point::class);
    }
    
    /**
     * Relationship to Points.
     * A user can have many points
     */
    public function likes(){
        return $this->hasMany(Like::class);
    }
    
    /**
     * Relationship to Points.
     * A user can have many points
     */
    public function posts(){
        return $this->hasMany(Post::class);
    }
    
    /**
     * Get points for this user.
     * @return integer points
     */
    public function getCurrentPoints(){
        return Point::where(Point::COL_USER_ID, $this->id)->sum(Point::COL_POINTS_ADDED);    
    }

}

 

Model for Posts (app/Model/Posts/Post.php)

namespace App\Model\Posts;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use DB;
use App\Model\Likes\Like;
use App\Model\User;

class Post extends Model
{
    use SoftDeletes;
    
    /* table column names */
    const COL_USER_ID = 'user_id';
    const COL_ID = 'ID';
    const COL_DELETED_AT = 'deleted_at';
    
        
    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable=[
        self::COL_USER_ID
    ];
    
    /**
     * The database table used by the Model.
     *
     * @var string
     */
    protected $table = 'posts';
    
    
    /**
     * Cascade Soft Delete.
     * Else if you delete a Post, 
     * it will just fully (hard) Delete the related children such as Likes
     */
    protected static function boot ()
    {
        parent::boot();
        
        self::deleting(function (Post $post) {
        
            //delete likes given to that post
            $post->likes->delete();
            
        });
    }    
    
    /**
     * Relation to Likes.
     * A post can have many likes.
     */
    public function likes(){
        return $this->morphMany(Like::class, Like::MORPH_TARGET);
    }
    
}

 

Model for Likes (app/Models/Likes/Like.php)

namespace App\Model\Likes;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth;
use Illuminate\Database\Eloquent\SoftDeletes;

class Like extends Model
{
    
    use SoftDeletes;
    
    /* Columns */
    const COL_USER_ID = 'user_id';
    const COL_TARGET_TYPE = 'target_type';
    const COL_TARGET_ID = 'target_id';
    
    /* Morph information */
    const MORPH_TARGET = 'target';
    const TYPE_POST_MODEL = 'App\Model\Posts\Post';
    
    
    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable=[self::COL_USER_ID,self::COL_TARGET_ID,self::COL_TARGET_TYPE];
    
    /**
     * The database table used by the Model.
     *
     * @var string
     */
    protected $table = 'likes';
    
    /**
     * Get all of the owning target models.
     */
    public function target()
    {
        return $this->morphTo();
    }
   
}

 

Model for Points (apps/Models/Points/Point.php)

namespace App\Model\Points;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Auth;

class Point extends InstanceLevel
{
    use SoftDeletes;
    
    /* Column Names */
    const COL_USER_ID = 'user_id';
    const COL_ORIGIN_TYPE = 'origin_type';
    const COL_ORIGIN_ID = 'origin_id';
    const COL_POINTS_ADDED = 'points_added';
    const COL_POINTS_GENRE = 'points_genre'; //enum [self,other]
    
    //self: points user got by own action(e.g. clicked like).
    //other: points user got by other persons action (e.g. somone else pressed like on users post).
    const GENRE_SELF = 'self';
    const GENRE_OTHER = 'other';
    const POINTS_GENRE_ENUM = [self::GENRE_SELF, self::GENRE_OTHER];
    
    //Origin Types
    const TYPE_LIKE_CLICKED = 'App\Model\Likes\Like';
    
    
    /*Base Points */
    const BASE_LIKE_CLICKED = 1;
    const BASE_LIKE_RECEIVED = 10;
    
    /**
     * The database table used by the Model.
     *
     * @var string
     */
    protected $table = 'points';
    
     /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = [self::COL_USER_ID, self::COL_ORIGIN_ID, self::COL_ORIGIN_TYPE, self::COL_POINTS_ADDED, self::COL_POINTS_GENRE];
    
    /**
     * Get all of the owning origin models.
     * @return type
     */
    public function origin(){
        return $this->morphTo();
    }
    
    /**
     * Get Total points a user got.
     * 
     * @param int $userId
     * @return integer
     */
    public static function getUserPoints($userId){
        return Point::where(self::COL_USER_ID, $userId)->sum(self::COL_POINTS_ADDED);
    }
    
}

 

Events

LikeClickedEvent (app/Events/LikeClickedEvent.php)

This event is for when a like button is clicked. It will be called in the controller when after the controller has handled a like button click request.


namespace App\Events;

use App\Events\Event;
use Illuminate\Queue\SerializesModels;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use App\Model\User;
use App\Model\Likes\Like;


class LikeClickedEvent extends Event
{
    use SerializesModels;
    
    public $user;
    public $like;
    

    /**
     * Create a new event instance.
     *
     * @return void
     */
    public function __construct(User $user, Like $like)
    {
        $this->user = $user;
        $this->like = $like;
    }

    /**
     * Get the channels the event should be broadcast on.
     *
     * @return array
     */
    public function broadcastOn()
    {
        return [];
    }
}

 

Listeners

LikePointsListener (app/Listeners/PointsListeners/LikePointsListener.php)

This listener will handle every event where Like was clicked in a queue. We will add all points that need to be attributed to different users in this Listener.
If you don't set up a queue, or are on a local machine, Laravel will automatically run the listener sequentially, which means running this code before returning the request to the user.
You want to return a request to the user as quickly as possible, and hence want to use queues which run such tasks in the background, as they are not directly related to the user's current action (in this case clicking a like button).
At Be&Do we use Redis to save queues and Supervisor to keep the queues running.
Read more about Queues on Laravels homepage: https://laravel.com/docs/5.3/queues (last accessed 2016/11/17).


namespace App\Listeners\PointsListeners;

use App\Events\LikeClickedEvent;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use App\Model\User;
use App\Model\Likes\Like;
use App\Model\Points\Point;

class LikePointsListener implements ShouldQueue
{
    use InteractsWithQueue;


    /**
     * Create the event listener.
     *
     * @return void
     */
    public function __construct()
    {
        //
    }

    /**
     * Handle the event.
     *
     * @param  LikeClickedEvent  $event
     * @return void
     */
    public function handle(LikeClickedEvent $event)
    {
        $this->handleLikeClickedEventPoints($event);
    }
    
    
    
    /**
     * Handle how much points the users (sender and receiver)
     * deserve for a like clicked.
     * @param LikeClickedEvent $event
     */
    private function handleLikeClickedEventPoints(LikeClickedEvent $event)
    {
     
        $like = $event->like;
        $sentUser = $event->user;
        $receiveUser = User::find($like->target->user_id);
        
        
        //create points for the user who clicked the like button
        $sentUserPoints = new Point();
        $sentUserNewPoints = Point::BASE_LIKE_CLICKED;
        $sentUserPoints->create([
            Point::COL_USER_ID => $sentUser->id,
            Point::COL_ORIGIN_ID => $like->id,
            Point::COL_ORIGIN_TYPE => Point::TYPE_LIKE_CLICKED,
            Point::COL_POINTS_ADDED => $sentUserNewPoints,
            Point::COL_POINTS_GENRE => Point::GENRE_SELF
        ]);

        
        //create points for the user who created what was liked, (if a user exists)
        if(!empty($receiveUser) ){
            $receiveUserPoints = new Point();
            
            /* 
             * Lets make the gamification more fun with a handicap:
             * If the user who clicked like has more points (eg. veteran) get more points.
             * Else if getting liked by a newcomer with less points, get less points
             */
            if($sentUser->getCurrentPoints() >= $receiveUser->getCurrentPoints()){
                $receiveUserNewPoints = Point::BASE_LIKE_RECEIVED * 1.2;
            }else{
                $receiveUserNewPoints = Point::BASE_LIKE_RECEIVED * 0.5;
            }

            $receiveUserPoints->create([
                Point::COL_USER_ID => $receiveUser->id,
                Point::COL_ORIGIN_ID => $like->id,
                Point::COL_ORIGIN_TYPE => Point::TYPE_LIKE_CLICKED,
                Point::COL_POINTS_ADDED => $receiveUserNewPoints,
                Point::COL_POINTS_GENRE => Point::GENRE_OTHER
            ]);

        }        
    }
}

 

EventServiceProvider

(※ This File is already part of the Laravel framework. It is normally located at app/Providers/EventServiceProvider.php)
Here we establish which listeners listen to (get triggered by) which events.
In your $listen array, add the the event as a key, and as value set all listeners that listen to that event.
Format: $listen = [ ‘event’ => [‘listener1’, ‘listener2’, ’listener3’]];
It should look similar to this:


    /**
     * The event listener mappings for the application.
     *
     * @var array
     */
    protected $listen = [
       'App\Events\LikeClickedEvent' => ['App\Listeners\PointsListeners\LikePointsListeners'],
    ];

 

Controllers

NewsfeedController (app/Http/Controllers/Newsfeed/NewsfeedController.php)

The controller which will display all the posts and in this case, to simplify, also takes care of our like button clicks. We have included some nonfunctional mock-up code to give an idea of the flow. Of course you would want to include some sort of validation.
The event for the like button click is called with: event(new LikeClickedEvent($user, $like));
With will send this event to the listeners associated in the EventServiceProvider.

 


namespace App\Http\Controllers\Newsfeed;

use Illuminate\Http\Request;

use App\Http\Requests;
use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\Auth;
use App\Model\User;
use App\Model\Posts\Post;
use App\Model\Likes\Like;
use App\Events\LikeClickedEvent;

class NewsfeedController extends Controller
{
    
    /**
     * Show all Posts.
     * @return view
     */
    public function index() {
        
        /* 
        --------------- MOCKUP CODE START:
        
        $posts = Posts::with('likes')->orderBy(Posts::COL_CREATED_AT, 'desc')->get();
        
        return view('newsfeed.newsfeedtop', [
            'posts' => $posts,
        ])
        
        --------------- MOCKUP END
        */
        
    }
    
    
    /**
     * Handle like button click on a post
     *
     * @return json
     */ 
    public function postLiked(Requests $request){
        
        /*    
        ----------- MOCKUP CODE START
        Handle like click
        
        for the event we need the $user and the $like as parameter.
        we assume something on the lines of:
        $user = Auth::User();
        $post = Post::find($request['post_id']);
        if(empty($post)){
           // return some error 
           // eg. return json_encode(['status'=>false, 'msg'=>'No such Post']);
        }
        $like = Like::create([
            Like::COL_USER_ID => $user->id,
            Like::COL_TARGET_TYPE => Like::TYPE_POST_MODEL,
            Like::COL_TARGET_ID => $post->id,
        ]);
        
        now we have a $user of type User and a $like of type App\Model\Likes\Like
        ----------- MOCKUP END
        */
        
        
        
        //broadcast new like in a likeClickedEvent
        event(new LikeClickedEvent($user, $like));
        
        //Return request to user. depending if you used Ajax or a normal request
        //Lets assume Ajax
        return json_encode(['status'=>true]);
        
    }
   
}


関連記事

ページ上部へ戻る