Nous allons découvrir aujourd'hui comment mettre en place un système de trophées / badges sur Symfony 3. Nous allons pour cela utiliser le système d'évènement afin de déclencher le déblocage de badge et de créer un système extensible. Pour ce système nous allons créer un Bundle afin de séparer la logique et de rendre notre code réutilisable.

Nos entités

Pour notre système nous allons créer 2 entités. La première entité permettra de stocker les noms des badges et les conditions de déblocage.

<?php

namespace Grafikart\BadgeBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * Badge
 *
 * @ORM\Table(name="badge")
 * @ORM\Entity(repositoryClass="Grafikart\BadgeBundle\Repository\BadgeRepository")
 */
class Badge
{
    /**
     * @var int
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @var string
     *
     * @ORM\Column(name="name", type="string", length=255, unique=true)
     */
    private $name;

    /**
     * Permet de sauvegarder l'action à effectuer pour débloquer le badge (par exemple "comment")
     *
     * @var string
     *
     * @ORM\Column(name="action_name", type="string", length=255)
     */
    private $actionName;

    /**
     * Permet de sauvegarder la quantité d'action à effectuer pour débloquer le badge
     *
     * @var int
     *
     * @ORM\Column(name="action_count", type="integer")
     */
    private $actionCount;

    /**
     * @var array
     *
     * @ORM\OneToMany(targetEntity="Grafikart\BadgeBundle\Entity\BadgeUnlock", mappedBy="badge")
     */
    private $unlocks;

    /**
    * ... GETTER / SETTER
    **/
}

On créera aussi une entité permettant de sauvegarder les déblocages. Cette entité servira de pivot et reliera les badges à nos utilisateurs.

<?php

namespace Grafikart\BadgeBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * BadgeUnlock
 *
 * @ORM\Table(name="badge_unlock")
 * @ORM\Entity(repositoryClass="Grafikart\BadgeBundle\Repository\BadgeUnlockRepository")
 */
class BadgeUnlock
{
    /**
     * @var int
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @var Badge
     *
     * @ORM\ManyToOne(targetEntity="Grafikart\BadgeBundle\Entity\Badge", inversedBy="unlocks")
     */
    private $badge;

    /**
     * @var \AppBundle\Entity\User
     *
     * @ORM\ManyToOne(targetEntity="AppBundle\Entity\User")
     */
    private $user;

    /**
    * ... GETTER / SETTER
    **/
}

Repository et Manager

Maintenant que nos entités sont posées on va créer un Repository qui regroupera les requêtes complexes à faire pour récupérer les badges.

<?php

namespace Grafikart\BadgeBundle\Repository;

use Grafikart\BadgeBundle\Entity\Badge;
use Doctrine\ORM\Query\Expr;

/**
 * BadgeRepository
 *
 * This class was generated by the Doctrine ORM. Add your own custom
 * repository methods below.
 */
class BadgeRepository extends \Doctrine\ORM\EntityRepository
{

    /**
     * @param int $user_id
     * @param string $action
     * @param int $action_count
     * @return Badge
     */
    public function findWithUnlockForAction(int $user_id, string $action, int $action_count): Badge {
        return $this->createQueryBuilder('b')
            ->where('b.actionName = :action_name')
            ->andWhere('b.actionCount = :action_count')
            ->andWhere('u.user = :user_id OR u.user IS NULL')
            ->leftJoin('b.unlocks', 'u', Expr\Join::WITH, 'u.user = :user_id')
            ->select('b, u')
            ->setParameters([
                'action_count'  => $action_count,
                'action_name'   => $action,
                'user_id'       => $user_id
            ])
            ->getQuery()
            ->getSingleResult();
    }

    /**
     * Find all badges unlocked by a specific user
     *
     * @param int $user_id
     * @return Badge[]
     */
    public function findUnlockedFor(int $user_id): array {
        return $this->createQueryBuilder('b')
            ->join('b.unlocks', 'u')
            ->where('u.user = :user_id')
            ->setParameter('user_id', $user_id)
            ->getQuery()
            ->getResult();
    }

}

La méthode findWithUnlockForAction() est importante car elle va permettre de récupérer un badge avec un left join sur la table pivot. Ce left join permet de déterminer si le badge est déjà débloqué pour l'utilisateur en question (sans nécessité une requête supplémentaire).

Enfin, on va créer un Manager qui permettra de piloter les fonctions de notre bundle depuis l'extérieur.

<?php
namespace Grafikart\BadgeBundle\Manager;

use AppBundle\Entity\User;
use Doctrine\Common\Persistence\ObjectManager;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\NoResultException;
use Grafikart\BadgeBundle\Entity\BadgeUnlock;
use Grafikart\BadgeBundle\Event\BadgeUnlockedEvent;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;

class BadgeManager {

    /**
     * @var ObjectManager
     */
    private $em;

    /**
     * @var EventDispatcherInterface
     */
    private $dispatcher;

    public function __construct(ObjectManager $manager, EventDispatcherInterface $dispatcher)
    {
        $this->em = $manager;
        $this->dispatcher = $dispatcher;
    }

    /**
     * Check if a badge exists for this action and action occurence and unlock it for the user
     *
     * @param User $user
     * @param string $action
     * @param int $action_count
     */
    public function checkAndUnlock(User $user, string $action, int $action_count): void {
        // Vérifier si on a un badge qui correspond à action et action count
        try {
            $badge = $this->em
                ->getRepository('BadgeBundle:Badge')
                ->findWithUnlockForAction($user->getId(), $action, $action_count);
            // Vérifier si l'utilisateur a déjà ce badge
            if ($badge->getUnlocks()->isEmpty()) {
                // Débloquer le badge pour l'utilisateur en question
                $unlock = new BadgeUnlock();
                $unlock->setBadge($badge);
                $unlock->setUser($user);
                $this->em->persist($unlock);
                $this->em->flush();
                // Emetter un évènement pour informer l'application du déblocage de bage
                $this->dispatcher->dispatch(BadgeUnlockedEvent::NAME, new BadgeUnlockedEvent($unlock));
            }
        } catch (NoResultException $e) {

        }
    }

    /**
     * Get Badges unlocked for the current user
     *
     * @param User $user
     * @return array
     */
    public function getBadgeFor (User $user): array {
        return $this->em->getRepository('BadgeBundle:Badge')->findUnlockedFor($user->getId());
    }

}

Lorsqu'un badge est débloqué on émettra un évènement. Cet évènement pourra ensuite être capturé par notre application afin d'y ajouter une logique supplémentaire (par exemple informé l'utilisateur par email, envoyer une notification push...).

Afin de faire fonctionner ce Manager et d'y injecter les dépendances on va l'ajouter au container à travers le fichier services.yml de notre bundle :

services:
    badge.manager:
        class: Grafikart\BadgeBundle\Manager\BadgeManager
        arguments: ['@doctrine.orm.entity_manager', '@event_dispatcher']

Comment utiliser ce bundle ?

Maintenant que l'on a posé la base de notre système on peut l'utiliser dans notre AppBundle au travers d'un Subscriber. Cette classe va permettre d'écouter un ensemble d'évènements et de réagir en fonction.

<?php
namespace AppBundle\Subscriber;

use AppBundle\Event\CommentCreatedEvent;
use AppBundle\Mailer\AppMailer;
use Doctrine\Common\Persistence\ObjectManager;
use Grafikart\BadgeBundle\Event\BadgeUnlockedEvent;
use Grafikart\BadgeBundle\Manager\BadgeManager;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

class BadgeSubscriber implements EventSubscriberInterface {

    /**
     * @var AppMailer
     */
    private $mailer;

    /**
     * @var ObjectManager
     */
    private $em;
    /**
     * @var BadgeManager
     */
    private $badgeManager;

    public function __construct(AppMailer $mailer, ObjectManager $em, BadgeManager $badgeManager)
    {
        $this->mailer = $mailer;
        $this->em = $em;
        $this->badgeManager = $badgeManager;
    }

    /**
     * List all events we are subscribing to
     * 
     * @return void
     */
    public static function getSubscribedEvents()
    {
        return [
            BadgeUnlockedEvent::NAME => 'onBadgeUnlock',
            CommentCreatedEvent::NAME=> 'onNewComment'
        ];
    }

    /**
     * When a badge is unlocked we send an email
     * 
     * @param BadgeUnlockedEvent $event
     * @return void
     */
    public function onBadgeUnlock(BadgeUnlockedEvent $event) {
        return $this->mailer->badgeUnlocked($event->getBadge(), $event->getUser());
    }

    /**
     * When a comment is created
     * 
     * @param CommentCreatedEvent $event
     * @return void
     */
    public function onNewComment (CommentCreatedEvent $event) {
        $user = $event->getComment()->getUser();
        $comments_count = $this->em->getRepository('AppBundle:Comment')->countForUser($user->getId());
        $this->badgeManager->checkAndUnlock($user, 'comment', $comments_count);
    }

}

Pour que notre subscriber fonctionne on le cable en utilisant le services.yml.

services:
    app.mailer:
        class: AppBundle\Mailer\AppMailer
        arguments: ['@mailer', '@templating']
    app.badge_subscriber:
        class: AppBundle\Subscriber\BadgeSubscriber
        arguments: ['@app.mailer', '@doctrine.orm.entity_manager', '@badge.manager']
        tags:
            - { name: kernel.event_subscriber }