最初に思いついたやり方

image

やってみた

今回は Symfony4 で実装した例を示しますが、コンセプトは Symfony2/3 でもまったく同じなので、適宜読み替えてください :pray:

エンティティ

<?php
namespace App\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

class User
{
    // ...

    /**
     * @var Collection|User[]
     *
     * @ORM\ManyToMany(targetEntity="User", mappedBy="followees")
     */
    public $followers;
    
    /**
     * @var Collection|User[]
     *
     * @ORM\ManyToMany(targetEntity="User", inversedBy="followers")
     * @ORM\JoinTable(
     *     name="followers_followees",
     *     joinColumns={@ORM\JoinColumn(name="follower_id", referencedColumnName="id")},
     *     inverseJoinColumns={@ORM\JoinColumn(name="followee_id", referencedColumnName="id")}
     * )
     */
    public $followees;

    public function __construct()
    {
        $this->followers = new ArrayCollection();
        $this->followees = new ArrayCollection();
    }

    public function follow(User $user): self
    {
        if ($user === $this) {
            throw new \LogicException('自分自身をフォローすることは出来ません。');
        }
        
        $this->followees->add($user);
        $user->followers->add($this);

        return $this;
    }

    public function unfollow(User $user): self
    {
        $this->followees->removeElement($user);
        $user->followers->removeElement($this);
        
        return $this;
    }

    public function __toString()
    {
        return $this->username;
    }

    // ...
}

コントローラー

<?php
namespace App\Controller;

use App\Entity\User;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;

/**
 * @Route("/user", name="user_")
 */
class UserController extends Controller
{
    // ...

    /**
     * @Route("/follow/{username}", name="follow")
     */
    public function follow(User $user, Request $request)
    {
        $this->getUser()->follow($user);

        $em = $this->getDoctrine()->getManager();

        // フォローする側もされる側も参照している中間テーブルのレコードは同じなので、片方だけ persist すればOK
        $em->persist($this->getUser());
        $em->flush();
        
        return $this->redirect($request->headers->get('referer'));
    }

    /**
     * @Route("/unfollow/{username}", name="unfollow")
     */
    public function unfollow(User $user, Request $request)
    {
        $this->getUser()->unfollow($user);

        $em = $this->getDoctrine()->getManager();

        // フォローする側もされる側も参照している中間テーブルのレコードは同じなので、片方だけ persist すればOK
        $em->persist($this->getUser());
        $em->flush();

        return $this->redirect($request->headers->get('referer'));
    }

    // ...
}

ビュー(フォロワー一覧を表示する例)

<ul>
    {% for follower in app.user.followers %}
        <li>{{ follower }}</li>
    {% else %}
        <li>フォロワーはまだいません。</li>
    {% endfor %}
</ul>

起こった問題

  • 中間テーブルは Doctrine が自動生成するので、フォローし始めた日時などの付帯情報を持たせることができない
  • 一般的には、フォロワー一覧画面はフォローし始めた日時の降順で並んでいることが多いので、そういう実装にしたい

解決策

  • ManyToMany をやめて、 User - 中間エンティティ - User の OneToMany - ManyToOne という構造にする
  • 中間エンティティにフォローし始めた日時(つまり中間エンティティの createdAt)を持たせる

image

やってみた

中間エンティティ

<?php
namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Knp\DoctrineBehaviors\Model\Timestampable\Timestampable;

/**
 * @ORM\Entity(repositoryClass="App\Repository\UserRelationRepository")
 * @ORM\Table(uniqueConstraints={@ORM\UniqueConstraint(name="unique_follower_followee", columns={"follower_id", "followee_id"})})
 */
class UserRelation
{
    use Timestampable;  // 今回は createdAt, updatedAt の記録はこの Trait に任せる

    // ...
    
    /**
     * @var User
     *
     * @ORM\ManyToOne(targetEntity="User", inversedBy="followeeRelations")
     * @ORM\JoinColumn(name="follower_id", referencedColumnName="id")
     */
    public $follower;

    /**
     * @var User
     *
     * @ORM\ManyToOne(targetEntity="User", inversedBy="followerRelations")
     * @ORM\JoinColumn(name="followee_id", referencedColumnName="id")
     */
    public $followee;

    public function __construct(User $follower, User $followee)
    {
        $this->follower = $follower;
        $this->followee = $followee;
    }

    // ...
}

解説

  • ここでは createdAt の記録に KnpLabs/DoctrineBehaviors を使っています
  • $follower に「フォローしている側の User」、 $followee に「フォローされている側の User」が入ります
  • $follower $followee の組み合わせに対して複合ユニーク制約を定義しています(クラスの @ORM\Table アノテーションで設定しています)
  • ManyToOne の設定内容は 公式ドキュメントに書かれている とおりです

User エンティティ

    // ...

    /**
-    * @var Collection|User[]
+    * @var Collection|UserRelation[]
*
-    * @ORM\ManyToMany(targetEntity="User", mappedBy="followees")
+    * @ORM\OneToMany(targetEntity="UserRelation", mappedBy="followee", cascade={"persist"}, orphanRemoval=true)
+    * @ORM\OrderBy({"createdAt"="DESC"})
     */
-   public $followers;
+   public $followerRelations;

    /**
-    * @var Collection|User[]
+    * @var Collection|UserRelation[]
     *
-    * @ORM\ManyToMany(targetEntity="User", inversedBy="followers")
-    * @ORM\JoinTable(
-    *     name="followers_followees",
-    *     joinColumns={@ORM\JoinColumn(name="follower_id", referencedColumnName="id")},
-    *     inverseJoinColumns={@ORM\JoinColumn(name="followee_id", referencedColumnName="id")}
-    * )
+    * @ORM\OneToMany(targetEntity="UserRelation", mappedBy="follower", cascade={"persist"}, orphanRemoval=true)
+    * @ORM\OrderBy({"createdAt"="DESC"})
     */
-   public $followees;
+   public $followeeRelations;

    // ...

    public function __construct()
    {
-       $this->followers = new ArrayCollection();
-       $this->followees = new ArrayCollection();
+       $this->followerRelations = new ArrayCollection();
+       $this->followeeRelations = new ArrayCollection();
    }

    public function follow(User $user): self
    {
        if ($user === $this) {
            throw new \LogicException('自分自身をフォローすることは出来ません。');
        }

-       $this->followees->add($user);
-       $user->followers->add($this);
+       $this->followeeRelations->add($relation = new UserRelation($this, $user));
+       $user->followerRelations->add($relation);

        return $this;
    }

    public function unfollow(User $user): self
    {
-       $this->followees->removeElement($user);
-       $user->followers->removeElement($this);
+       foreach ($this->followeeRelations as $followeeRelation) {
+           if ($followeeRelation->followee === $user) {
+               $this->followeeRelations->removeElement($followeeRelation);
+               break;
+           }
+       }
+    
+       foreach ($user->followerRelations as $followerRelation) {
+           if ($followerRelation->follower === $this) {
+               $user->followerRelations->removeElement($followerRelation);
+               break;
+           }
+       }

        return $this;
    }

+   /**
+    * @return User[]
+    */
+   public function getFollowers(): array
+   {
+       return $this->followerRelations->map(function (UserRelation $userRelation) {
+           return $userRelation->follower;
+       })->toArray();
+   }
+
+   /**
+    * @return User[]
+    */
+   public function getFollowees(): array
+   {
+       return $this->followeeRelations->map(function (UserRelation $userRelation) {
+           return $userRelation->followee;
+       })->toArray();
+   }

    // ...

解説

User と UserRelation の関係

  • $followers $followees の代わりに $followerRelations $followeeRelations という中間テーブルのコレクションを持たせました
  • @ORM\OrderBy アノテーションUserRelation::createdAt の降順で取得するようにしています

cascade の設定

  • @ORM\OneToMany アノテーションの中で、 cascade={"persist"}orphanRemoval=true を設定しています
  • cascade={"persist"} によって、User を persist したときに一緒に UserRelation も persist されるようになります
    • なので、コントローラーのコードには UserRelation を persist するコードを追記する必要はなく、先ほどのコードから変更はありません
  • orphanRemoval=true によって、User::$followerRelationsUser::$followeeRelations から UserRelation のインスタンスが削除された(関係が断たれて宙ぶらりんになった)ときに、当該の UserRelation が remove されるようになります

cascade の設定は followerRelations/followeeRelations 両方に必要?

  • $followerRelations$followeeRelations の両方に cascade={"persist"}orphanRemoval=true を設定しています
  • 今回のようにコントローラーが「フォローする側(される側ではなく)の User」「フォロー解除する側(される側ではなく)の User」だけを persist するようなコードになっている場合、$followerRelations にだけ設定してあれば問題なく動作します
  • ここでは、逆のコードが出てきた場合に備えて一応両方に設定してあります

followers/followees へのアクシビリティ

  • フォロワーやフォロイーのリストを外部に公開するために、getFollowers() getFollowees() というメソッドを作ってあります
    • これにより、ビューのコードには変更がありません

コントローラー

変更なし。

先述のとおり、User エンティティを変更するときに cascade 設定によって UserRelation エンティティも適切に変更・削除されるため、コントローラーは UserRelation エンティティの存在を意識する必要がありません。

ビュー

変更なし。

先述のとおり、User エンティティに getFollowers() getFollowees() を実装しておいたので、ビューからはあたかも $followers $followees にアクセスするかのようにフォロワーリスト・フォロイーリストを取得できます。

サンプルコードとライブデモ

サンプルコードとライブデモを用意してみたので、ぜひコードを確認したり手元で動かしたりしてみてください!

:point_right: サンプルコード
https://github.com/ttskch/symfony4-doctrine2-user-following-feature-sample

:point_right: ライブデモ
http://symfony4-doctrine2-user-follow.herokuapp.com

おわりに

ググっても意外と日本語の情報がなさそうだったので書いてみました。 もっとエレガントな方法あるよ!という方がいらっしゃいましたらぜひ @ttskch までコメントをお寄せください :pray: