Cours pour apprendre à utiliser le framework Laravel 5.5

Les données


précédentsommairesuivant

VIII. Gérer un arbre

On a souvent le cas d'une structure arborescente dans une base de données, par exemple pour un menu, des catégories et sous-catégories. Il existe deux grandes façons d'aborder et résoudre ce problème :

  • on peut se contenter de prévoir une colonne pour mémoriser l'identifiant du parent. C'est simple mais contraint à une approche récursive pour retrouver les données ;
  • on peut approcher ce problème sous forme d'ensembles imbriqués, c'est plus complexe mais bien plus performant (pour une explication en français c'est ici).

Dans un premier temps j'avais adopté la première approche puis je suis passé à la seconde parce que je n'étais pas satisfait du résultat en matière de performances.

Plutôt que de coder tout ça je me suis appuyé sur un package déjà tout prêt. J'ai un peu hésité parce qu'il ne semble pas être maintenu : la dernière modification remonte à 3 ans. D'ailleurs les commandes Artisan ne sont pas toutes fonctionnelles. Mais le jeu en vaut quand même la chandelle comme vous allez le voir dans cet article.

J'ai utilisé ce package pour la gestion des commentaires sur les articles.

VIII-A. Aspect fonctionnel

Dans l'application d'exemple les utilisateurs inscrits peuvent laisser des commentaires :

Image non disponible

On peut commenter un commentaire et ainsi de suite. On a donc une organisation hiérarchique qui en plus correspond à la même entité.

Le niveau d'imbrication est réglable dans l'administration :

Image non disponible

VIII-B. Installer un package

L'installation d'un package dans Laravel est en général très simple et se limite le plus souvent à informer composer. Pour le cas de etrepat/baum il suffit de taper :

 
Sélectionnez
composer require baum/baum

Et le package se charge dans le dossier vendor. D'autre part le fichier composer.json est modifié :

 
Sélectionnez
1.
2.
3.
4.
5.
"require": {
    ...
    "baum/baum": "^1.1",
    ...
},

Il faut ensuite informer Laravel que le package existe dans le fichier config/app.php :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
'providers' => [

...

/*
 * Package Service Providers...
 */
Baum\Providers\BaumServiceProvider::class,

Cette étape est maintenant devenue inutile pour les packages prévus pour Laravel 5.5.

Selon les packages il est ensuite parfois nécessaire de lancer d'autres opérations pour publier une configuration spécifique, des vues… Ce n'est pas le cas avec notre package.

VIII-C. Les données

VIII-C-1. Les migrations

Pour fonctionner notre package a besoin que la table concernée comporte 4 colonnes particulières. Il faut donc les prévoir dans la migration :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
class CreateCommentsTable extends Migration {

  public function up()
  {
      Schema::create('comments', function(Blueprint $table) {
           ...
           $table->integer('parent_id')->nullable()->index();
           $table->integer('lft')->nullable()->index();
           $table->integer('rgt')->nullable()->index();
           $table->integer('depth')->nullable();
           ...
      });
  }

On va donc les retrouver dans la table :

Image non disponible

VIII-C-2. Les modèles

Le modèle pour les commentaires ne doit pas étendre la classe Illuminate\Database\Eloquent\Model mais la classe du package Baum/Node :

 
Sélectionnez
use Baum\Node;

class Comment extends Node

VIII-C-3. Les relations

La table des commentaires (comments) est en relation avec trois autres tables :

  • posts : une relation 1:n (clé étrangère : post_id) parce que les articles peuvent avoir plusieurs commentaires ;
  • users : une relation 1:n (clé étrangère : user_id) parce que les utilisateurs peuvent écrire plusieurs commentaires ;
  • comments : une relation 1:n avec elle-même (clé étrangère : parent_id) parce que les commentaires peuvent avoir plusieurs commentaires enfants.
Image non disponible

Peut-être cela vous semble-t-il un peu étrange cette relation réflexive des commentaires sur eux-même mais ça fonctionne.

D'autre part on pourrait à partir de cette situation établir d'autres relations. La table comments peut très bien servir de pivot entre les tables users et posts et on pourrait établir des relations belongsToMany. Mais on n'en a pas besoin parce qu'il n'est pas vraiment utile de connaître pour un utilisateur tous les commentaires qu'il a écrits ou pour un article tous les utilisateurs qui l’ont commenté. Mais rien n'empêche d'établir plusieurs sortes de relations sur une même table, Eloquent permet cette souplesse. Une relation ne sert que lorsqu'elle est utilisée.

Si vous regardez dans le modèle Post vous trouvez ce code :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
/**
 * One to Many relation
 *
 * @return \Illuminate\Database\Eloquent\Relations\HasMany
 */
public function comments()
{
    return $this->hasMany(Comment::class);
}

/**
 * One to Many relation
 *
 * @return \Illuminate\Database\Eloquent\Relations\hasMany
 */
public function validComments()
{
    return $this->comments()->whereHas('user', function ($query) {
        $query->whereValid(true);
    });
}

/**
 * One to Many relation
 *
 * @return \Illuminate\Database\Eloquent\Relations\hasMany
 */
public function parentComments()
{
    return $this->validComments()->whereParentId(null);
}

Voyons ça de plus près :

  • comments : c'est la relation hasMany classique qu'on a déjà vue ;
  • validComments : c'est la relation hasMany complétée par une contrainte. En effet lorsqu'un utilisateur commente la première fois il doit être modéré, il n'est pas encore valide, il y a une colonne booléenne valid dans la table users. Ici on vérifie donc que l'utilisateur est valide parce qu'on ne doit pas encore afficher ses commentaires ;
  • parentComments : on veut les commentaires valides mais seulement ceux de premier niveau (qui ne sont donc pas un commentaire de commentaire), on teste juste que la clé étrangère a la valeur nulle, qu'il n'y a donc pas de parent.

Dans les deux autres modèles on a des relations classiques, belongsTo dans Comment et hasMany dans User.

VIII-D. Création d'un commentaire

VIII-D-1. Les routes

Si vous regardez dans le fichier des routes routes/web.php vous trouvez ces deux routes :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
// Posts and comments
Route::prefix('posts')->namespace('Front')->group(function () {
    ...
    Route::name('posts.comments.store')->post('{post}/comments', 'CommentController@store');
    Route::name('posts.comments.comments.store')->post('{post}/comments/{comment}/comments', 'CommentController@store');
    ...
});

Vous remarquez que les routes sont dans un groupe pour mutualiser :

  • le préfixe post ;
  • l'espace de noms Front pour les contrôleurs.

Les deux routes correspondent à ces URL :

  1. post/{post}/comments
  2. post/{post}/comments/{comment}/comments

Pourquoi deux routes pour créer les commentaires ?

Parce qu'on a deux cas à traiter :

1 - Le cas du commentaire parent créé avec le formulaire :

Image non disponible

Dans ce cas on a une soumission classique du formulaire et on transmet dans l'URL l'identifiant de l'article avec le paramètre {post}.

2 - Le cas de la réponse à un commentaire existant :

Image non disponible

Dans ce cas la soumission est faite en Ajax, pour éviter de recharger toute la page, et on transmet dans l'URL l'identifiant du commentaire parent avec le paramètre {comment} en plus de celui de l'article.

VIII-D-2. Le contrôleur

La création d'un commentaire est traité dans la méthode store du contrôleur Front/CommentController :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
/**
 * Store a newly created comment in storage.
 *
 * @param  \App\http\requests\CommentRequest $request
 * @param  \App\Models\Post  $post
 * @param  integer $comment_id
 * @return \Illuminate\Http\Response
 */
public function store(CommentRequest $request, Post $post, $comment_id = null)
{
    Comment::create ([
        'body' => $request->input('message' . $comment_id),
        'post_id' => $post->id,
        'user_id' => $request->user()->id,
        'parent_id' => $comment_id,
    ]);

    ...

    if (!$request->user()->valid) {
        $request->session()->flash('warning', __('Thanks for your comment. It will appear when an administrator has validated it.<br>Once you are validated your other comments immediately appear.'));
    }

    if($request->ajax()) {
        return response()->json();
    }

    return back();
}

Regardez la signature de la méthode :

 
Sélectionnez
public function store(CommentRequest $request, Post $post, $comment_id = null)

On a 3 paramètres :

  • $request : injection de la requête de formulaire pour la validation comme on l'a déjà vu ;
  • $post : liaison implicite entre le paramètre {post} et le modèle Post, je rappelle qu'ainsi Laravel injecte directement une instance du modèle avec l'identifiant $post ;
  • $comment_id : récupère le paramètre {comment} avec l'identifiant du commentaire parent ou rien du tout dans le cas d'un commentaire de premier niveau et dans ce cas on a la valeur par défaut null.

Dans la méthode on crée le commentaire avec la méthode store :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
Comment::create ([
    'body' => $request->input('message' . $comment_id),
    'post_id' => $post->id,
    'user_id' => $request->user()->id,
    'parent_id' => $comment_id,
]);

Si l'utilisateur n'est pas valide (c'est son premier commentaire) on lui envoie un message :

 
Sélectionnez
if (!$request->user()->valid) {
    $request->session()->flash('warning', __('Thanks for your comment. It will appear when an administrator has validated it.<br>Once you are validated your other comments immediately appear.'));
}

Remarquez comment on peut récupérer une instance du modèle de l'utilisateur en cours avec $request->user(). D'autre part on envoie le texte du message en session valable juste pour une requête HTTP (flash). Le texte est de base en anglais, je parlerai dans un chapitre ultérieur de la localisation.

Ensuite, selon que la requête est en Ajax ou pas, on envoie la réponse adaptée :

 
Sélectionnez
1.
2.
3.
4.
5.
if($request->ajax()) {
    return response()->json();
}

return back();

VIII-E. Modification d'un commentaire

Dans l'application le rédacteur d'un commentaire peut ensuite le modifier. Il dispose d'un lien en forme d'icône représentant un stylo :

Image non disponible

Ça ouvre un formulaire avec le texte actuel :

Image non disponible

La soumission se fait en Ajax pour éviter de recharger la page. C'est la méthode update du contrôleur Front/CommentController qui gère cette modification :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
public function update(CommentRequest $request, Comment $comment)
{
    ...

    $message = $request->input('message' . $comment->id);
    $comment->body = $message;
    $comment->save();

    return ['id' => $comment->id, 'message' => $message];
}

On a :

  • une requête de formulaire pour la validation (CommentRequest) ;
  • une liaison implicite entre le paramètre {comment} et le modèle Comment.

La mise à jour se fait classiquement avec la méthode save. On retourne un tableau avec l'identifiant et le texte que Laravel transforme automatiquement en réponse JSON.

VIII-F. Suppression d'un commentaire

Dans l'application le rédacteur d'un commentaire peut ensuite le supprimer s'il a des remords. Il dispose d'un lien en forme d'icône représentant une poubelle :

Image non disponible

On a dans la page un formulaire classique caché :

 
Sélectionnez
1.
2.
3.
4.
<form action="http://monsite.fr/comments/16" method="POST" class="hide">
  <input type="hidden" name="_token" value="EK86XinyEke12ERSRx5Ddfu8hD6iNBBUIzUSe34y">
  <input type="hidden" name="_method" value="DELETE">
</form>

C'est la méthode destroy du contrôleur Front/CommentController qui gère cette suppression :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
public function destroy(Comment $comment)
{
    ...

    $comment->delete();

    return back();
}

On a une liaison implicite entre le paramètre {comment} et le modèle Comment.

La suppression se fait classiquement avec la méthode delete.

VIII-G. Affichage des commentaires

L'affichage des commentaires se fait lorsqu'on affiche un article. C'est géré par la méthode show du contrôleur Front/PostController :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
public function show(Request $request, $slug)
{
    $user = $request->user();

    return view('front.post', array_merge($this->postRepository->getPostBySlug($slug), compact('user')));
}

On récupère l'utilisateur en cours avec $request->user() et les informations nécessaires dans le repository PostRepository (méthode getPostBySlug). On a déjà commencé à voir cette méthode dans le chapitre sur la relation 1:nLa relation 1:n :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
public function getPostBySlug($slug)
{
    // Post for slug with user, tags and categories
    $post = $this->model->with([
        'user' => function ($q) {
            $q->select('id', 'name', 'email');
        },
        'tags' => function ($q) {
            $q->select('tags.id', 'tag');
        },
        'categories' => function ($q) {
            $q->select('title', 'slug');
        }
    ])
    ->with(['parentComments' => function ($q) {
        $q->with('user')
            ->latest()
            ->take(config('app.numberParentComments'));
    }])
    ->withCount('validComments')
    ->withCount('parentComments')
    ->whereSlug($slug)
    ->firstOrFail();

    // Previous post
    $post->previous = $this->getPreviousPost($post->id);

    // Next post
    $post->next = $this->getNextPost($post->id);

    return compact('post');
}

On va s'intéresser cette fois à cette partie du code :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
->with(['parentComments' => function ($q) {
    $q->with('user')
        ->latest()
        ->take(config('app.numberParentComments'));
}])
->withCount('validComments')
->withCount('parentComments')

On charge (with) les commentaires de niveau un (parentComments) ainsi que les rédacteurs des commentaires (with('user')), à partir du plus récent (latest) et on ne prend (take) que ce qui est indiqué dans la configuration (app.numberParentComments).

D'autre part on récupère aussi le nombre de commentaires valides (->withCount('validComments')) et le nombre de commentaires de niveau un (->withCount('parentComments')).

Dans la vue front/post.blade.php on a ce code :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
@if ($post->valid_comments_count)
    ...
    <ol class="commentlist">
        @include('front/comments/comments', ['comments' => $post->parentComments])
    </ol>
    ...
@endif

Si on a des commentaires valides on inclut (@include) la vue front/comments/comments :

 
Sélectionnez
@foreach($comments as $comment)
    @include('front/comments/comments-base')
@endforeach

Dans cette vue pour chaque (@foreach) commentaire on inclut la vue front/comments/comments-base. Je ne vais pas analyser tout le code mais me focaliser sur une partie intéressante :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
@unless ($comment->isLeaf())
    ...
    <ul class="children">
        @include('front/comments/comments', ['comments' => $comment->getImmediateDescendants()])
    </ul>
@endunless

À moins que (@unless) le commentaire n'ait plus d'enfant ($comment->isLeaf()) on inclut à nouveau la vue front/comments/comments en passant en paramètre tous les commentaires immédiatement descendants ($comment->getImmediateDescendants()). On itère donc dans tous les commentaires pour les afficher de façon hiérarchique.

La méthode getImmediateDescendants fait partie des nombreuses méthodes disponibles avec le package pour questionner les nœuds.

La méthode isLeaf fait partie des nombreuses méthodes disponibles avec le package pour accéder aux nœuds.

VIII-H. Afficher plus de commentaires

On a vu que dans la configuration (config/app.php) on limite le nombre de commentaires de premier niveau à afficher, par défaut la valeur est 2 :

 
Sélectionnez
'numberParentComments' => 2,

S'il y a encore des commentaires à afficher on présente un bouton :

Image non disponible

On trouve ce code dans la vue front/post.blade.php pour gérer l'apparition conditionnelle du bouton :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
@if ($post->parent_comments_count > config('app.numberParentComments'))
    <p id="morebutton" class="text-center">
        <a id="nextcomments" href="{{ route('posts.comments', [$post->id, 1]) }}" class="button">@lang('More comments')</a>
    </p>
    ...
@endif

La requête passe en Ajax pour éviter de recharger la page. Elle est gérée par la méthode comments du contrôleur Front/CommentController :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
public function comments(Post $post, $page)
{
    $comments = $this->commentRepository->getNextComments($post, $page);
    $count = $post->parentComments()->count();
    $level = 0;

    return [
        'html' => view('front/comments/comments', compact('post', 'comments', 'level'))->render(),
        'href' => $count <= config('app.numberParentComments') * ++$page ?
            'none'
            : route('posts.comments', [$post->id, $page]),
    ];
}

C'est le repository CommentRepository qui est chargé de récupérer les commentaires :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
public function getNextComments(Post $post, $page)
{
    return $post->parentComments()
        ->with('user')
        ->latest()
        ->skip($page * config('app.numberParentComments'))
        ->take(config('app.numberParentComments'))
        ->get();
}

On prend :

  • les commentaires de niveau un (parentComments) ;
  • en chargeant (with) les rédacteurs (user) ;
  • en commençant par les plus récents (latest) ;
  • en sautant (skip) ceux qui sont déjà affichés ;
  • en prenant (take) la quantité définie dans la configuration.

VIII-I. La population (seeding)

Je ne vous ai pas encore parlé des factories… Les factories sont des classes principalement consacrées aux tests mais qui peuvent aussi servir en d'autres occasions comme ici pour créer des enregistrements. On les trouve ici :

Image non disponible

Par défaut on a juste la classe factory pour le modèle User :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
$factory->define(App\User::class, function (Faker $faker) {
    static $password;

    return [
        'name' => $faker->name,
        'email' => $faker->unique()->safeEmail,
        'password' => $password ?: $password = bcrypt('secret'),
        'remember_token' => str_random(10),
    ];
});

Sans entrer dans les détails, on utilise le composant Faker pour générer des données aléatoires pour les propriétés du modèle User. Par exemple avec ce code :

 
Sélectionnez
factory(User::class, 15)->create();

On crée directement quinze utilisateurs !

Voici la classe factory pour les commentaires (CommentFactory) :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
$factory->define(App\Models\Comment::class, function (Faker $faker) {

    return [
        'body' => $faker->paragraph($nbSentences = 4, $variableNbSentences = true),
    ];
});

Ensuite les commentaires peuvent facilement être créés en ajoutant les informations manquantes dans la classe factory (ou en les surchargeant) :

 
Sélectionnez
1.
2.
3.
4.
$comment1 = factory(Comment::class)->create([
    'post_id' => 2,
    'user_id' => 3,
]);

On utilise une méthode du package baum (makeChildOf) pour hiérarchiser les commentaires :

 
Sélectionnez
1.
2.
3.
4.
5.
factory(Comment::class)->create([
    'post_id' => 4,
    'user_id' => 5,
    //'parent_id' => $nbrComments,
])->makeChildOf($comment2);

VIII-J. En résumé

On a vu dans ce chapitre comment utiliser un package pour gérer une situation particulière, ici un arbre de données hiérarchiques en simplifiant ainsi le codage final.


précédentsommairesuivant

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

Copyright © 2018 Laravel. Aucune reproduction, même partielle, ne peut être faite de ce site ni de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.