Laravel 5

Cours Laravel 5.3 – les données – la relation n:n (2/2)

Dans ce chapitre on va poursuivre la réalisation du petit blog avec des tags qu’on a commencé au précédent chapitre. On va voir le fonctionnement de l’ensemble ainsi que les vues.

La liste des articles

La méthode du repository pour la liste des articles est modifiée et renommée pour ajouter la table tags :

public function getWithUserAndTagsPaginate($n)
{
    return $this->queryWithUserAndTags()->paginate($n);
}

Pour clarifier le code j’ai créé une fonction protégée qui va nous servir une autre fois :

protected function queryWithUserAndTags()
{
    return $this->post->with('user', 'tags')->latest();
}

‌Vous remarquez qu’on a ajouté la table tags comme paramètre de la méthode with en plus de users. On va en effet avoir besoin des informations des tags pour l’affichage dans la vue.

Il est intéressant de voir les requêtes générées par Eloquent, par exemple pour la première page :

select count(*) as aggregate from `posts`

select * from `posts` order by `created_at` desc limit 4 offset 0

select * from `users` where `users`.`id` in ('3', '2', '4', '5')

select `tags`.*, `post_tag`.`post_id` as `pivot_post_id`, `post_tag`.`tag_id` as `pivot_tag_id` from `tags` inner join `post_tag` on `tags`.`id` = `post_tag`.`tag_id` where `post_tag`.`post_id` in ('6', '4', '9', '14')

select * from `users` where `users`.`id` = '1' limit 1

On voit que :

  • on demande le nombre total d’articles pour la pagination,
  • on demande les 4 premières lignes des articles avec l’ordre des dates,
  • on demande les utilisateurs qui correspondent aux articles sélectionnés,
  • on demande les tags concernés par les articles.

On se rend compte là du travail effectué par Eloquent pour nous !

Nouvel article

L’enregistrement d’un nouvel article va évidemment être un peu plus délicat à cause de la présence des tags. Dans le repository des articles (PostRepository) on va se contenter d’enregistrer l’article :

public function store($inputs)
{
    return $this->post->create($inputs);
}

C’est dans le repository des tags que le plus gros du travail va se faire :

<?php
public function store($post, $tags)
{
    $tags = explode(',', $tags);

    foreach ($tags as $tag) {

        $tag = trim($tag);

        $tag_url = Str::slug($tag);

        $tag_ref = $this->tag->where('tag_url', $tag_url)->first();

        if(is_null($tag_ref)) 
        {
            $tag_ref = new $this->tag([
                'tag' => $tag,
                'tag_url' => $tag_url
            ]); 

            $post->tags()->save($tag_ref);

        } else {
        
            $post->tags()->attach($tag_ref->id);

        }
    }
}

Ce code mérite quelques commentaires. Les tags sont envoyés par le formulaire (que nous verrons plus loin) sous la forme de texte avec comme séparateur une virgule. Par exemple :

tag1,tag2,tag3

Dans le contrôleur la première chose est de vérifier qu’il y a des tags saisis :

if(isset($inputs['tags'])) 
{
    $tagRepository->store($post, $inputs['tags']);
}

Si c’est le cas on appelle la méthode store du repository en transmettant les tags et une référence du modèle créé. Dans le repository on crée un tableau en utilisant le séparateur (virgule) :

$tags = explode(',', $tags);

Ensuite on parcourt le tableau :

foreach ($tags as $tag)

Par précaution on supprime les espaces éventuels

$tag = trim($tag);

On crée la version pour url du tag (avec la méthode slug de la classe Str) :

$tag_url = Str::slug($tag);

On regarde si ce tag existe déjà :

$tag_ref = $this->tag->where('tag_url', $tag_url)->first();

On peut aussi utiliser cette syntaxe pour le where :

$tag_ref = $this->tag->whereTagUrl($tag_url)->first();

Si ce n’est pas le cas on le crée :

$tag_ref = new $this->tag([
    'tag' => $tag,
	'tag_url' => $tag_url
]);	
$post->tags()->save($tag_ref);

Remarquez comment la méthode save ici permet à la fois de créer le tag et de référencer la table pivot.

Si le tag existe déjà on se contente d’informer la table pivot avec la méthode attach :

$post->tags()->attach($tag_ref->id);

Suppression d’un article

Quand on va supprimer un article il faudra aussi supprimer les liens avec les tags :

public function destroy(Post $post)
{
    $post->tags()->detach();
    $post->delete();
}

La méthode detach permet de supprimer les lignes dans la table pivot.

La recherche par tag

Il nous reste enfin à voir la recherche par sélection d’un tag :

public function getWithUserAndTagsForTagPaginate($tag, $n)
{
    return $this->queryWithUserAndTags()
        ->whereHas('tags', function($query) use ($tag) {
            $query->where('tags.tag_url', $tag);
        })->paginate($n);
}

Vous remarquez que par rapport au code de la méthode getWithUserAndTagsPaginate on a ajouté la méthode whereHas. Cette méthode permet d’ajouter une condition sur une table chargée. Il est intéressant là aussi de voir les requêtes générées par Eloquent :

select count(*) as aggregate from `posts` where exists (select * from `tags` inner join `post_tag` on `tags`.`id` = `post_tag`.`tag_id` where `post_tag`.`post_id` = `posts`.`id` and `tags`.`tag_url` = 'omnis')

select * from `posts` where exists (select * from `tags` inner join `post_tag` on `tags`.`id` = `post_tag`.`tag_id` where `post_tag`.`post_id` = `posts`.`id` and `tags`.`tag_url` = 'omnis') order by `created_at` desc limit 4 offset 0

select * from `users` where `users`.`id` in ('3', '2', '4')

select `tags`.*, `post_tag`.`post_id` as `pivot_post_id`, `post_tag`.`tag_id` as `pivot_tag_id` from `tags` inner join `post_tag` on `tags`.`id` = `post_tag`.`tag_id` where `post_tag`.`post_id` in ('6', '4', '8', '10')

select * from `users` where `users`.`id` = '1' limit 1

Il y en a 5 plutôt chargées :

  • on compte les enregistrements pour la pagination (avec une jointure),
  • on récupère les 4 lignes des articles (avec une jointure),
  • on récupère les utilisateurs rédacteurs des articles,
  • on récupère les tags concernés par les articles (avec une jointure).

Il y a une chose que je n’ai pas géré dans tout ce code, c’est le cas des tags orphelins en cas de suppression d’un article. Cette gestion n’est pas obligatoire parce qu’il n’est pas vraiment gênant d’avoir des tags orphelins. On pourrait prévoir une maintenance épisodique de la base (tâche CRON par exemple) ou une action de l’administrateur.

Le template

On conserve le même template (resources/views/layouts/app.blade.php) :

<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <!-- CSRF Token -->
    <meta name="csrf-token" content="{{ csrf_token() }}">

    <title>Mon joli blog</title>

    <!-- Styles -->
    <link href="/css/app.css" rel="stylesheet">

    <!-- Scripts -->
    <script>
        window.Laravel = <?php echo json_encode([
            'csrfToken' => csrf_token(),
        ]); ?>
    </script>
</head>
<body>
    <nav class="navbar navbar-default navbar-static-top">
        <div class="container">
            <div class="navbar-header">

                <!-- Collapsed Hamburger -->
                <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#app-navbar-collapse">
                    <span class="sr-only">Toggle Navigation</span>
                    <span class="icon-bar"></span>
                    <span class="icon-bar"></span>
                    <span class="icon-bar"></span>
                </button>

                <!-- Branding Image -->
                <a class="navbar-brand" href="{{ url('/') }}">
                    Mon joli Blog
                </a>
            </div>

            <div class="collapse navbar-collapse" id="app-navbar-collapse">
                <!-- Left Side Of Navbar -->
                <ul class="nav navbar-nav">
                     
                </ul>

                <!-- Right Side Of Navbar -->
                <ul class="nav navbar-nav navbar-right">
                    <!-- Authentication Links -->
                    @if (Auth::guest())
                        <li><a href="{{ url('/login') }}">Se connecter</a></li>
                        <li><a href="{{ url('/register') }}">S'enregistrer</a></li>
                    @else
                        <li><a href="{{ url('/post/create') }}">Créer un article</a></li>
                        <li class="dropdown">
                            <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-expanded="false">
                                {{ Auth::user()->name }} <span class="caret"></span>
                            </a>

                            <ul class="dropdown-menu" role="menu">
                                <li>
                                    <a href="{{ url('/logout') }}"
                                        onclick="event.preventDefault();
                                                 document.getElementById('logout-form').submit();">
                                        Logout
                                    </a>

                                    <form id="logout-form" action="{{ url('/logout') }}" method="POST" style="display: none;">
                                        {{ csrf_field() }}
                                    </form>
                                </li>
                            </ul>
                        </li>
                    @endif
                </ul>
            </div>
        </div>
    </nav>

    @yield('content')

    <!-- Scripts -->
    <script src="/js/app.js"></script>
</body>
</html>

La liste des articles

Voici la vue pour la liste des articles (resources/views/posts/liste.blade.php) :

@extends('layouts.app')

@section('content')
    <div class="container">
        @if(isset($info))
            <div class="row alert alert-info">{{ $info }}</div>
        @endif
        {!! $posts->links() !!}
        @foreach($posts as $post)
            <article class="row bg-primary">
                <div class="col-md-12">
                    <header>
                        <h1>{{ $post->titre }}
                            <div class="pull-right">
                                @foreach($post->tags as $tag)
                                    <a href="{{ url('post/tag/' . $tag->tag_url) }}" class="btn btn-xs btn-info">{{ $tag->tag }}</a></li>
                                @endforeach
                            </div>
                        </h1>
                    </header>
                    <hr>
                    <section>
                        <p>{{ $post->contenu }}</p>
                        @if(auth()->check() and auth()->user()->admin)
                            <form method="POST" action="{{ route('post.destroy', ['id' => $post->id]) }}">
                                {{ method_field('DELETE') }}
                                {{ csrf_field() }}
                                <input class="btn btn-danger btn-xs" onclick="return confirm('Vraiment supprimer cet article ?')" type="submit" value="Supprimer cet article">
                            </form>
                        @endif
                        <em class="pull-right">
                            {{ $post->user->name }} le {!! $post->created_at->format('d-m-Y') !!}
                        </em>
                    </section>
                </div>
            </article>
            <br>
        @endforeach
        {!! $posts->links() !!}
    </div>
@endsection

Avec cet aspect :

Les tags apparaissent sous la forme de petits boutons. Le fait de cliquer sur un de ces boutons lance la recherche à partir de ce tag et affiche les articles correspondant ainsi qu’une barre d’information :

Un utilisateur connecté dispose en plus du lien pour créer un article. L’administrateur a en plus le bouton de suppression :

La création d’un article

Le formulaire de création d’un article (resources/views/posts/add.blade.php) a été enrichi d’un contrôle de texte pour la saisie des tags :

@extends('layouts.app')

@section('content')
    <div class="col-sm-offset-3 col-sm-6">
        <div class="panel panel-default">
            <div class="panel-heading">Ajout d'un article</div>
            <div class="panel-body"> 
                <form method="POST" action="{{ url('/post') }}">
                    {{ csrf_field() }}
                    <div class="form-group{{ $errors->has('titre') ? ' has-error' : '' }}">
                        <input class="form-control" placeholder="Titre" name="titre" type="text" value="{{ old('titre') }}" autofocus>
                        @if ($errors->has('titre'))
                            <span class="help-block">
                                <strong>{{ $errors->first('titre') }}</strong>
                            </span>
                        @endif
                    </div>
                    <div class="form-group{{ $errors->has('contenu') ? ' has-error' : '' }}">
                        <textarea class="form-control" placeholder="Contenu" name="contenu" cols="50" rows="10">{{ old('contenu') }}</textarea>
                        @if ($errors->has('contenu'))
                            <span class="help-block">
                                <strong>{{ $errors->first('contenu') }}</strong>
                            </span>
                        @endif
                    </div>
                    <div class="form-group{{ $errors->has('tags') ? ' has-error' : '' }}">
                        <input class="form-control" placeholder="Entrez les tags séparés par des virgules" name="tags" type="text" value="{{ old('tags') }}">
                        @if ($errors->has('tags'))
                            <span class="help-block">
                                <strong>{{ $errors->first('tags') }}</strong>
                            </span>
                        @endif
                    </div>
                    <button type="submit" class="btn btn-primary pull-right">Envoyer !</button>
                </form>

            </div>
        </div>
    </div>
@endsection

Est aussi géré le message d’erreur pour la validation des tags :

Je ne détaille pas le code de toutes ces vues, il n’est pas bien compliqué et recouvre des situations déjà rencontrées.

En résumé

  • Eloquent permet de générer de nombreuses requêtes SQL à partir de simples méthodes explicites.
  • On n’a pas besoin de modèle pour la table pivot qui est prise en charge complètement par les modèles encadrants.
Print Friendly, PDF & Email

7 commentaires

  • cedcomps

    Bonjour,
    Je voulais vous remercier pour ce que vous faîtes j’apprend Laravel pour la première fois et vous expliquez clairement grace à ce tutoriel , juste génial!
    Je me suis lancé le défi de le suivre mais sous 5.4 alors peut etre que ca peut poser un conflit quelque part mais lorsque je cherche par tag il m’indique que la méthode [indexTag@index] n’existe pas.
    Pourtant j’ai revérifier sur votre site, sur openclassroom (d’ou je viens d’ailleurs en tant qu’étudiant) et sur developpez.com et nous avons les mêmes lignes. Sauriez vous si la version de Laravel 5.4 peut avoir des changements sur la manière dont les appels sont fait par rapport a la 5.3?

    Cordialement et encore une fois un énorme merci pour le temps passé à rédiger ce cours

    • bestmomo

      Bonjour,

      Merci pour les encouragements !

      Pour la question la méthode indexTag@index ne peut pas être trouvée parce que le contrôleur indexTag n’existe pas. Quelle est le code des routes où se trouve cet appel ?

      • cedcomps

        Ma debug bar n’apparait plus ce matin PhpDebugbar is not defined … Donc j’ai été cherché dans l’application pour le coup dans mon fichier de route elle est déclarée ici comme vous le faites
        Route::resource(‘post/tag/{tag}’, ‘PostController@indexTag’);

        Puis le PostController a la méthode indexTag dans son fichier comme suit
        public function indexTag($tag)
        {
        $posts = $this->postRepository->getWithUserAndTagsForTagPaginate($tag, $this->nbrPerPage);

        return view(‘posts.liste’, compact(‘posts’, ‘links’))
        ->with(‘info’, ‘Résultats pour la recherche du mot-clé : ‘ . $tag);
        }
        Donc la déclaration est faite ? Ou alors ne devrait il pas afficher PostController@indexTag selon la convention d’écriture sur Laravel?

          • cedcomps

            Justement à la bonne surprise il est introuvable d’où la colle que je pose car j’ai fait la recherche aussi avant de poster :/ !

          • cedcomps

            Alors autant pour moi, a force de lire et relire on pense que c’est bon mais non. Le problème se situait même dans mon 1er commentaire !

            Route::resource(‘post/tag/{tag}’, ‘PostController@indexTag’);

            « resource » au lieu d’un simple « get »

            Je ne comprenais pas pourquoi il appelait une méthode inexistante… le chemin n’allait pas

Laisser un commentaire