最初に思いついたやり方
-
User
とUser
を ManyToMany で繋ぐ - ググってみてもだいたいこの方法が紹介されている
やってみた
今回は Symfony4 で実装した例を示しますが、コンセプトは Symfony2/3 でもまったく同じなので、適宜読み替えてください
エンティティ
<?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;
}
// ...
}
- 自己参照で ManyToMany する方法については 公式ドキュメントに解説があります
$followers
$followees
を配列ではなくArrayCollection
にしている理由は 公式ドキュメントのここ をご参照ください
コントローラー
<?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
)を持たせる
やってみた
中間エンティティ
<?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::$followerRelations
やUser::$followeeRelations
からUserRelation
のインスタンスが削除された(関係が断たれて宙ぶらりんになった)ときに、当該のUserRelation
が remove されるようになります-
orphanRemoval
についての詳細は 公式ドキュメント をご参照いただくか、弊社技術ブログの過去記事 が参考になるかもしれません
-
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
にアクセスするかのようにフォロワーリスト・フォロイーリストを取得できます。
サンプルコードとライブデモ
サンプルコードとライブデモを用意してみたので、ぜひコードを確認したり手元で動かしたりしてみてください!
サンプルコード
https://github.com/ttskch/symfony4-doctrine2-user-following-feature-sample
ライブデモ
http://symfony4-doctrine2-user-follow.herokuapp.com
おわりに
ググっても意外と日本語の情報がなさそうだったので書いてみました。 もっとエレガントな方法あるよ!という方がいらっしゃいましたらぜひ @ttskch までコメントをお寄せください