Tutoriel Vidéo PHP Plugin de commentaire

Télécharger la vidéo Télécharger les sources

Dans ce tutoriel je vous propose de découvrir comment créer un système de commentaire modulable et réutilisable (une sorte de plugin). A la fin de ce tutoriel on aura une class Comments que l'on pourra utiliser (et réutiliser) dans nos différents projets.

Objectif

Avant de se lancer dans le code il faut réfléchir à ce que l'on cherche obtenir :

  • Se créer un "plugin" que l'on va pouvoir utiliser
  • Avoir qqchose qui s'adapte à la pluspart des cas
  • On ne s'occupera pas du code HTML (varie entre chaque projet)
  • Doit être indépendant de la structure du code

On va donc utiliser la programmation orientée objet en se créant une classe qui va contenir nos différentes fonctions. Pour le nom, on ne va pas faire original mais gràce à l'utilisation des namespace on n'a pas trop à s'inquieter d'eventuels conflits.

<?php
namespace Grafikart\Plugin;

class Comments{

}

Cette super classe va devoir intéragir avec notre base de donnée donc il va nous falloir utiliser une connexion (on choisira pdo ici). Cette connexion sera passée dans le constructeur.

...
class Comments{

    private $pdo; 

    public function __construct($pdo){
        $this->pdo = $pdo; 
    }

}

Mais on veut aussi avoir quelquechose de personnalisable donc on va accepter un second paramètre options qui contiendra nos différents paramètres

...
class Comments{

    private $pdo;
    private $default = array(
        'username_error' => "Vous n'avez pas rentré de pseudo",
        'email_error' => "Votre email n'est pas valide",
        'content_error' => "Vous n'avez pas mis de message",
        'parent_error' => "Vous essazer de répondre à un commentaire qui n'existe pas"
    );

    public function __construct($pdo, $options = []) {
        $this->pdo = $pdo;
        $this->options = array_merge($this->default, $options);
    }

}

Donc là on a une base simple la classe pourra être initialisé simplement en lui envoyer une connexion PDO avec (ou sans) un tableau d'option.

Récupération des commentaires

Avant de vouloir récupérer les commentaires on va poser la structure de notre base de donnée :

  • id (int, clef primaire)
  • username (varchar, pseudo)
  • email (varchar)
  • content (mediumtext)
  • ref (varchar, la table associée au commentaire)
  • ref_id (int, l'enregistrement associé au commentaire)
  • created (datetime, date de création)

On va commencer le coeur de notre class par une fonction simple : Récupérer les commentaires. Pour cela on créera une fonction qui prend 2 paramètres (ref et ref_id). On pourra ajouter plus de paramètres (facultatifs) par la suite pour l'améliorer.

public function findAll($ref, $ref_id) {
    $q = $this->pdo->prepare("
        SELECT *
        FROM comments
        WHERE ref_id = :ref_id
            AND ref = :ref
        ORDER BY created DESC");
    $q->execute(['ref' => $ref, 'ref_id' => $ref_id]);
    $comments = $q->fetchAll();
    $this->count = count($comments); // On stocke le compteur dans l'instance
    return $comments; 
}

Rien de bien exceptionel à ce niveau là, une petite requête et l'affaire est dans le sac. Juste une précision concernant l'ordre, en mettant created DESC on récupère les commentaires du plus récent au plus vieux.

Sauvegarde des commentaires

Lire des commentaires c'est bien, en écrire c'est mieux ^^. La fonction prendra encore les mêmes paramètres que notre fonction de récupération. Inutile de lui passer des données car elle seront accessible à travers $_POST.

public function save($ref, $ref_id) {
    // @todo Valider les données
    $q = $this->pdo->prepare("INSERT INTO comments SET
        username = :username,
        email    = :email,
        content  = :content,
        ref_id   = :ref_id,
        ref      = :ref,
        created  = :created,
        parent_id= :parent_id");
    $data = [
        'username' => $_POST['username'],
        'email'    => $_POST['email'],
        'content'  => $_POST['content'],
        'parent_id'=> $_POST['parent_id'],
        'ref_id'   => $ref_id,
        'ref'      => $ref,
        'created'  => date('Y-m-d H:i:s')
    ];
    return $q->execute($data);
}

Mais en l'état c'est dangereux, il faut valider les données histoire de s'assurer que les différents champs sont remplis et surtout conforme. On va créer une variable d'instance $errors qui contiendra les erreurs (sous forme de tableau indexé par le nom du champs erroné). Si il y a une erreur on retournera directement false et on arrêtera l'exécution de notre fonction save().

public function save($ref, $ref_id) {
    $errors = [];
    if (empty($_POST['username'])) {
        $errors['username'] = $this->options['username_error'];
    }
    if (
        empty($_POST['email']) ||
        !filter_var($_POST['email'], FILTER_VALIDATE_EMAIL)) {
        $errors['email'] = $this->options['email_error'];
    }
    if (empty($_POST['content'])) {
        $errors['content'] = $this->options['content_error'];
    }
    if (count($errors) > 0) {
        $this->errors = $errors;
        return false;
    }
    @todo : mettre un système antispam (akismet ou recaptcha)
    $q = $this->pdo->prepare("INSERT INTO comments SET
        username = :username,
        email    = :email,
        content  = :content,
        ref_id   = :ref_id,
        ref      = :ref,
        created  = :created");
    $data = [
        'username' => $_POST['username'],
        'email'    => $_POST['email'],
        'content'  => $_POST['content'],
        'ref_id'   => $ref_id,
        'ref'      => $ref,
        'created'  => date('Y-m-d H:i:s')
    ];
    return $q->execute($data);
}

Et voila ! on a notre classe de base facile à utiliser

$comments_cls = new Grafikart\Plugin\Comments($db);
// Si des données ont été postées
if (isset($_POST['action']) && $_POST['action'] == 'comment') {
    if ($comments->save('article', 2)) {
        $success = true; 
    } else {
        $success = false; 
    }
}

// Si on veut des commentaires ?
$comments = $comments_cls->findAll('article', 2); 

Simple non ? Je ne traiterai pas la partie antispam, elle a déjà été évoquée dans des tutoriels dédiés.

Fonction répondre

Nos commentaires sont sympas mais c'est un peu plat, il faut permettre aux gens de répondre. Pour cela on va rajouter un champs parent_id qui contiendra l'id du commentaire parent.

Fonction récupération

On va donc modifier notre fonction de récupération pour injecter un attribut replies qui va contenir l'ensemble des réponses.

public function findAll($ref, $ref_id) {
    $q = $this->pdo->prepare("
        SELECT *
        FROM comments
        WHERE ref_id = :ref_id
            AND ref = :ref
        ORDER BY created DESC");
    $q->execute(['ref' => $ref, 'ref_id' => $ref_id]);
    $comments = $q->fetchAll();
    $this->count = count($comments);

    // Filtrage des réponses
    $replies  = [];
    foreach($comments as $k => $comment){
        if ($comment->parent_id){
            $replies[$comment->parent_id][] = $comment;
            unset($comments[$k]);
        }
    }
    foreach($comments as $k => $comment){
        if (isset($replies[$comment->id])) {
            $comments[$k]->replies = array_reverse($replies[$comment->id]);
        }else{
            $comments[$k]->replies = [];
        }
    }
    return $comments;
}
  • En résumé on crée un tableau $replies qui va contenir nos réponses indexés par le parent_id.
  • On parcourt les commentaires, si le commentaire a un parent_id alors on le supprime du tableau général et on le met dans le tableau replies. On fait un petit array_reverse pour récupérer les réponses du plus vieux au plus récent (l'inverse de l'ordre des commentaires)
  • On reparcourt les commentaires et on injecte le tableau $replies dans un attribut replies (je part du principe que pdo récupère les donnés sous forme d'objet, si on voulait optimiser on préciserais dans $q le format des résultats pour ne pas avoir de surprise).

Sauvegarde

Je ne vais pas parler de la partie javascript (elle est traitée dans la vidéo) mais l'idée est de rajouter un champs parent_id qui vaudra 0 par défaut mais qui pourra contenir l'id du commentaire parent. Il va donc falloir valider ce champs là en vérifiant que le parent existe et que ce n'est pas déjà une réponse (on ne veut pas des réponses imbriquées à l'infini car c'est chiant à lire).

public function findAll($ref, $ref_id) {
    $q = $this->pdo->prepare("
        SELECT *
        FROM comments
        WHERE ref_id = :ref_id
            AND ref = :ref
        ORDER BY created DESC");
    $q->execute(['ref' => $ref, 'ref_id' => $ref_id]);
    $comments = $q->fetchAll();
    $this->count = count($comments);

    // Filtrage des réponses
    $replies  = [];
    foreach($comments as $k => $comment){
        if ($comment->parent_id){
            $replies[$comment->parent_id][] = $comment;
            unset($comments[$k]);
        }
    }
    foreach($comments as $k => $comment){
        if (isset($replies[$comment->id])) {
            $r = $replies[$comment->id];
            usort($r, [$this,'sortReplies']);
            $comments[$k]->replies = $r;
        }else{
            $comments[$k]->replies = [];
        }
    }
    return $comments;
}

La classe finale

Pour récapituler voici notre classe bien formaté (et avec des commentaires).

<?php
namespace Grafikart\Plugin;

/**
 * Permet de mettre en place un système de commentaire (récupération et sauvegarde pour n'importe quel contenu)
 */
class Comments{

    private $pdo;
    private $default = array(
        'username_error' => "Vous n'avez pas rentré de pseudo",
        'email_error' => "Votre email n'est pas valide",
        'content_error' => "Vous n'avez pas mis de message",
        'parent_error' => "Vous essazer de répondre à un commentaire qui n'existe pas"
    );
    public $errors = array();
    public $count  = 0;

    /**
     * Permet d'initialiser le module commentaire
     * @param PDO $pdo instance d'une connection mysql via pdo
     * @param array $options
     */
    public function __construct($pdo, $options = []) {
        $this->pdo = $pdo;
        $this->options = array_merge($this->default, $options);
    }

    /**
     * Permet de récupérer les derniers commentaires d'un sujet
     * @param  string $ref
     * @param  integer $ref_id
     * @return array
     */
    public function findAll($ref, $ref_id) {
        $q = $this->pdo->prepare("
            SELECT *
            FROM comments
            WHERE ref_id = :ref_id
                AND ref = :ref
            ORDER BY created DESC");
        $q->execute(['ref' => $ref, 'ref_id' => $ref_id]);
        $comments = $q->fetchAll();
        $this->count = count($comments);

        // Filtrage des réponses
        $replies  = [];
        foreach($comments as $k => $comment){
            if ($comment->parent_id){
                $replies[$comment->parent_id][] = $comment;
                unset($comments[$k]);
            }
        }
        foreach($comments as $k => $comment){
            if (isset($replies[$comment->id])) {
                $comments[$k]->replies = array_reverse($replies[$comment->id]);
            }else{
                $comments[$k]->replies = [];
            }
        }
        return $comments;
    }

    /**
     * Permet de sauvegarder un commentaire
     * @param  string $ref
     * @param  integer $ref_id
     * @return boolean
     */
    public function save($ref, $ref_id) {
        $errors = [];
        if (empty($_POST['username'])) {
            $errors['username'] = $this->options['username_error'];
        }
        if (
            empty($_POST['email']) ||
            !filter_var($_POST['email'], FILTER_VALIDATE_EMAIL)) {
            $errors['email'] = $this->options['email_error'];
        }
        if (empty($_POST['content'])) {
            $errors['content'] = $this->options['content_error'];
        }
        if (count($errors) > 0) {
            $this->errors = $errors;
            return false;
        }
        if (!empty($_POST['parent_id'])) {
            $q = $this->pdo->prepare("SELECT id
                FROM comments
                WHERE ref = :ref AND ref_id = :ref_id AND id = :id AND parent_id = 0");
            $q->execute([
                'ref' => $ref,
                'ref_id' => $ref_id,
                'id' => $_POST['parent_id']
            ]);
            if($q->rowCount() <= 0){
                $this->errors['parent'] = $this->options['parent_error'];
                return false;
            }
        }
        $q = $this->pdo->prepare("INSERT INTO comments SET
            username = :username,
            email    = :email,
            content  = :content,
            ref_id   = :ref_id,
            ref      = :ref,
            created  = :created,
            parent_id= :parent_id");
        $data = [
            'username' => $_POST['username'],
            'email'    => $_POST['email'],
            'content'  => $_POST['content'],
            'parent_id'=> $_POST['parent_id'],
            'ref_id'   => $ref_id,
            'ref'      => $ref,
            'created'  => date('Y-m-d H:i:s')
        ];
        return $q->execute($data);
    }

}