cult3

Creating the Twitter Follower Model using Doctrine

Nov 24, 2014

Table of contents:

  1. A quick recap
  2. The functionality we’re going to build
  3. Using Doctrine’s ArrayCollection
  4. Writing the relationship annotation
  5. The Follow and Following methods
  6. The Followers method
  7. Unfollowing a User
  8. Conclusion

A very common requirement of social consumer web applications is the ability for users to “follow” other users. This is typically in the form of Twitter’s follower model where a user can follow another user without reciprocation.

This social functionality has become such a common aspect of consumer web applications that new applications are almost expected to have it.

However, allowing your users to follow each other isn’t just a standard bit of functionality that you are expected to implement. Having a social dynamic makes your product more engaging and a better way to surface relevant content to other users.

It can also be a big reason why new users give your product a shot in the first place and why existing user’s decide to stick around.

In today’s tutorial we’re going to be looking at implementing the Twitter follower model using Doctrine as our ORM of choice.

A quick recap

If you are a long-time reader of Culttt, you might remember that we already looked at Creating the Twitter following model in Laravel 4.

This previous tutorial was mostly Laravel 4 specific code and used Laravel’s Eloquent as the ORM.

This tutorial follows the same concept as the implementation in the previous post. However, for this tutorial we’re going to be looking at how you can implement the same idea but using Doctrine 2’s ORM capabilities.

If you are looking to implement this functionality using Laravel’s Eloquent, take a look at that previous post.

The functionality we’re going to build

So before I jump into the code, first I’ll give a little overview as to what we are going to build today.

I’m going to be emulating the Twitter follower model where a user can follow any other user without reciprocation. This is in contrast to the Facebook social model where both users enter a “friendship”.

We’re going to need a Many-to-Many, Self-referencing relationship. This means both sides of the relationship use the same Entity.

Each User object will have a following and followers collection of User objects that represents who they are following and who is following them.

Using Doctrine’s ArrayCollection

So the first thing we need to do is to create two new instances of ArrayCollection in the __construct() method of the User object:

/**
 * Create a new User
 *
 * @param UserId $userId
 * @param Email $email
 * @param Username $username
 * @param HashedPassword $password
 * @return void
 */
private function __construct(UserId $userId, Email $email, Username $username, HashedPassword $password)
{
    $this->setId($userId);
    $this->setEmail($email);
    $this->setUsername($username);
    $this->setPassword($password);

    $this->followers = new ArrayCollection;
    $this->following = new ArrayCollection;

    $this->record(new UserHasRegistered);
}

The ArrayCollection object is really just an object orientated version of an array and so instantiating in the __construct() is fine. You are not coupling yourself to Doctrine when you do this because ArrayCollection has no dependencies and so it does not tie you to Doctrine.

Writing the relationship annotation

Next we need to write the relationship annotations for the two class properties we set up.

/**
 * @ORM\ManyToMany(targetEntity="User", mappedBy="following")
 **/
private $followers;

This annotation is simply setting the target Entity and defining the property that this relationship should be mapped to.

Next we have the other side of the relationship:

/**
 * @ORM\ManyToMany(targetEntity="User", inversedBy="followers")
 * @ORM\JoinTable(name="followers",
 * joinColumns={@ORM\JoinColumn(name="user_id", referencedColumnName="id")},
 * inverseJoinColumns={@ORM\JoinColumn(name="following_user_id", referencedColumnName="id")}
 * )
 */
private $following;

This annotation is more complicated because we need to explicitly tell Doctrine the table and columns to use. In a more traditional Many-to-Many relationship you wouldn’t need to be this explicit.

If you don’t want to use annotations in your Entities, you could always use XML or YAML.

The Follow and Following methods

Now that we have the relationship in place we can add the method to allow a user to follow another user and a method to return the collection of users that the user follows.

However, before we write that code, first we can add a test to the UserTest class to test this new functionality:

/** @test */
public function should_follow_and_have_followers()
{
    $user1 = User::register(
        new UserId(Uuid::uuid4()),
        new Email('jack@twitter.com'),
        new Username('jack'),
        new HashedPassword('square')
    );

    $user2 = User::register(
        new UserId(Uuid::uuid4()),
        new Email('ev@twitter.com'),
        new Username('ev'),
        new HashedPassword('medium')
    );

    $user3 = User::register(
        new UserId(Uuid::uuid4()),
        new Email('biz@twitter.com'),
        new Username('biz'),
        new HashedPassword('jelly')
    );

    $user4 = User::register(
        new UserId(Uuid::uuid4()),
        new Email('dick@twitter.com'),
        new Username('dick'),
        new HashedPassword('feedburner')
    );

    $user2->follow($user3);
    $user2->follow($user4);
    $user3->follow($user4);
    $user4->follow($user1);
    $user4->follow($user2);
    $user4->follow($user3);
}

In this test method I’ve created 4 users and then randomly made them follow each other.

So the first thing we need to do is to create the follow() method on the User class:

/**
 * Follow another User
 *
 * @param User $user
 * @return void
 */
public function follow(User $user)
{
    $this->following[] = $user;
}

The follow method should expect an instance of User and then that user should be added to the following collection.

We can also write a method to return the user’s following collection:

/**
 * Return the Users this User is following
 *
 * @return ArrayCollection
 */
public function following()
{
    return $this->following;
}

To test that these two methods are working correctly we can make the following assertions:

$this->assertEquals(0, $user1->following()->count());
$this->assertEquals(2, $user2->following()->count());
$this->assertEquals(1, $user3->following()->count());
$this->assertEquals(3, $user4->following()->count());

The Followers method

We also need to be able to get the followers of the user, so we can add this method to return the followers collection:

/**
 * Return the User's followers
 *
 * @return ArrayCollection
 */
public function followers()
{
    return $this->followers;
}

We can also add assertions to ensure the method is working correctly:

$this->assertEquals(1, $user1->followers()->count());
$this->assertEquals(0, $user1->following()->count());
$this->assertEquals(1, $user2->followers()->count());
$this->assertEquals(2, $user2->following()->count());
$this->assertEquals(2, $user3->followers()->count());
$this->assertEquals(1, $user3->following()->count());
$this->assertEquals(2, $user4->followers()->count());
$this->assertEquals(3, $user4->following()->count());

Now if you run those tests, they should all pass green…

Oh oh! Something went wrong! The followers() method doesn’t seem to be returning the correct count!

If you remember back to Working with relationships in Doctrine 2, Doctrine has a concept of the Owning Side and the Inverse Side.

In a bi-directional relationship we need to keep both sides of the relationship in sync if we want to be able to access the relationship on both sides. If this concept doesn’t make sense, have a read of Working with relationships in Doctrine 2.

To solve this problem we can create a private method called followedBy() and use it like this:

/**
 * Follow another User
 *
 * @param User $user
 * @return void
 */
public function follow(User $user)
{
    $this->following[] = $user;

    $user->followedBy($this);
}

/**
 * Set followed by User
 *
 * @param User $user
 * @return void
 */
private function followedBy(User $user)
{
    $this->followers[] = $user;
}

When a user follows another user, the other side of the relationship will be automatically updated too.

Now if you run those tests again they should all pass green.

Unfollowing a User

The process of unfollowing a user is basically just the opposite of following a user. Instead of adding the user to the collection, we simply need to remove the user.

To do this we can use the following two methods:

/**
 * Unfollow a User
 *
 * @param User $user
 * @return void
 */
public function unfollow(User $user)
{
    $this->following->removeElement($user);

    $user->unfollowedBy($this);
}

/**
 * Set unfollowed by a User
 *
 * @param User $user
 * @return void
 */
private function unfollowedBy(User $user)
{
    $this->followers->removeElement($user);
}

The first method is the public method for unfollowing a user. The second method is the private method for updating the other side of the relationship.

To ensure that the process for following and unfollowing users is working correctly we can extend the test we wrote earlier:

/** @test */
public function should_follow_and_have_followers()
{
    $user1 = User::register(
        new UserId(Uuid::uuid4()),
        new Email('jack@twitter.com'),
        new Username('jack'),
        new HashedPassword('square')
    );

    $user2 = User::register(
        new UserId(Uuid::uuid4()),
        new Email('ev@twitter.com'),
        new Username('ev'),
        new HashedPassword('medium')
    );

    $user3 = User::register(
        new UserId(Uuid::uuid4()),
        new Email('biz@twitter.com'),
        new Username('biz'),
        new HashedPassword('jelly')
    );

    $user4 = User::register(
        new UserId(Uuid::uuid4()),
        new Email('dick@twitter.com'),
        new Username('dick'),
        new HashedPassword('feedburner')
    );

    $user2->follow($user3);
    $user2->follow($user4);
    $user3->follow($user4);
    $user4->follow($user1);
    $user4->follow($user2);
    $user4->follow($user3);

    $this->assertEquals(1, $user1->followers()->count());
    $this->assertEquals(0, $user1->following()->count());
    $this->assertEquals(1, $user2->followers()->count());
    $this->assertEquals(2, $user2->following()->count());
    $this->assertEquals(2, $user3->followers()->count());
    $this->assertEquals(1, $user3->following()->count());
    $this->assertEquals(2, $user4->followers()->count());
    $this->assertEquals(3, $user4->following()->count());

    $user2->unfollow($user3);
    $user4->unfollow($user3);

    $this->assertEquals(1, $user1->followers()->count());
    $this->assertEquals(0, $user1->following()->count());
    $this->assertEquals(1, $user2->followers()->count());
    $this->assertEquals(1, $user2->following()->count());
    $this->assertEquals(0, $user3->followers()->count());
    $this->assertEquals(1, $user3->following()->count());
    $this->assertEquals(2, $user4->followers()->count());
    $this->assertEquals(2, $user4->following()->count());
}

This test is asserting that we can follow users and then unfollow users correctly. One of the beautiful things about using Doctrine as our ORM is the fact that we didn’t need to hit the database once during this test.

Conclusion

Creating the Twitter follower model is pretty simple once you know how to do it, but it can be difficult to get your head around when you first think about how to implement it.

Doctrine’s relationships can also be a bit strange when you first start to implement them because they are a bit more intricate than your typical Active Record style association.

However with that being said, hopefully this was a nice introduction to implementing Doctrine relationships on your Entities. We’re going to be implementing a lot more relationships for this application!

This is a series of posts on building an entire Open Source application called Cribbb. All of the tutorials will be free to web, and all of the code is available on GitHub.

Philip Brown

@philipbrown

© Yellow Flag Ltd 2024.