Laravel 8

Créer un blog – les articles

Nous avons dans le précédent article mis en place la page d’accueil avec la barre de navigation, le diaporama (heros), les résumés des articles (bricks), la pagination adaptée. Dans cet article nous allons maintenant voir une fonctionnalité importante : l’affichage des articles. Il faut afficher encore la barre de navigation mais elle va devoir être un peu adaptée. On doit aussi afficher l’image de l’article puis son contenu. Il faut aussi rappeler le nom de l’auteur, les catégories et les étiquettes. Enfin on doit renseigner les liens vers les articles précédents et suivants. On va en profiter par ajouter la possibilité d’obtenir les articles par catégorie, par auteur et par étiquette. Et pour finir on codera la recherche.

Pour les commentaires ça attendra un prochain article parce que c’est une partie un peu délicate.

Vous pouvez télécharger le code final de cet article ici.

Les avatars

Pour la gestion des avatars on va utiliser un package :

composer require creativeorange/gravatar ~1.0

On va utiliser la façade alors on la déclare dans app/config/app.php :

'aliases' => [
    ...
    'Gravatar' => Creativeorange\Gravatar\Facades\Gravatar::class,
];

On ne va pas publier la configuration parce que les valeurs par défaut conviennent.

Le repository

Au niveau du repository (PostRepository) on a besoin de récupérer :

  • un article à partir de son slug
  • l’article éventuel qui précède
  • l’article éventuel qui suit

Ce qui donne ce code :

public function getPostBySlug($slug)
{
    // Post for slug with user, tags and categories
    $post = Post::with(
                'user:id,name,email',
                'tags:id,tag,slug',
                'categories:title,slug'
            )
            ->withCount('validComments')
            ->whereSlug($slug)
            ->firstOrFail();

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

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

    return $post;
}

protected function getPreviousPost($id)
{
    return Post::select('title', 'slug')
                ->whereActive(true)
                ->latest('id')
                ->firstWhere('id', '<', $id);
}

protected function getNextPost($id)
{
    return Post::select('title', 'slug')
                ->whereActive(true)
                ->oldest('id')
                ->firstWhere('id', '>', $id);
}

Pour un article à afficher on collecte toutes les informations nécessaires :

  • toutes les colonnes de l’article
  • le nom et l’email de l’auteur
  • le slug et le nom des étiquettes
  • le slug et le nom des catégories associées
  • le compte des commentaires pour savoir s’il y en a
  • les éventuels articles précédents et suivants avec leur titre et leur slug

Le contrôleur et la route

Au niveau du repository (PostController) on a besoin d’une méthode :

public function show(Request $request, $slug)
{
    $post = $this->postRepository->getPostBySlug($slug);

    return view('front.post', compact('post'));
}

On ajoute la route :

Route::prefix('posts')->group(function () {
    Route::name('posts.display')->get('{slug}', [FrontPostController::class, 'show']);
});

J’ai créé un groupe parce qu’on aura d’autres routes avec ce préfixe.

Les helpers

On va ajouter un helper (dans app/helpers) pour formatter correctement les dates :

if (!function_exists('formatDate')) {
  function formatDate($date)
  {
      return ucfirst(utf8_encode ($date->formatLocalized('%d %B %Y')));
  }
}

La vue front.post

On crée une nouvelle vue :

Avec ce code :

@extends('front.layout')

@section('main')

<!-- post
================================================== -->
<div class="row">
  <div class="column large-12">

      <article class="s-content__entry format-standard">

          <div class="s-content__media">
              <div class="s-content__post-thumb">
                  <img src="{{ getImage($post) }}" alt="" style="width:100%">
              </div>
          </div>

          <div class="s-content__entry-header">
              <h1 class="s-content__title s-content__title--post">{{ $post->title }}</h1>
          </div>

          <div class="s-content__primary">

              <div class="s-content__entry-content">

                  {!! $post->body !!}

              </div>

              <div class="s-content__entry-meta">

                  <div class="entry-author meta-blk">
                      <div class="author-avatar">
                          <img class="avatar" src="{{ Gravatar::get($post->user->email) }}" alt="">
                      </div>
                      <div class="byline">
                          <span class="bytext">@lang('Posted By')</span>
                          <a href="#0">{{ $post->user->name }}</a>
                      </div>
                  </div>

                  <div class="meta-bottom">
                      
                      <div class="entry-cat-links meta-blk">
                          <div class="cat-links">
                              <span>@lang('In')</span> 
                              @foreach ($post->categories as $category)
                                  <a href="#">{{ $category->title }}</a>
                              @endforeach
                          </div>

                          <span>@lang('On')</span>
                          {{ formatDate($post->created_at) }}
                      </div>

                      <div class="entry-tags meta-blk">
                          <span class="tagtext">@lang('Tags')</span>
                          @foreach($post->tags as $tag)
                              <a href="#">{{ $tag->tag }}</a>
                          @endforeach
                      </div>

                  </div>

              </div>

              <div class="s-content__pagenav">
                  @isset($post->previous)
                      <div class="prev-nav">
                          <a href="{{ route('posts.display', $post->previous->slug) }}" rel="prev">
                              <span>@lang('Previous')</span>
                              {{ $post->previous->title }}
                          </a>
                      </div>
                  @endisset
                  @isset($post->next)
                      <div class="next-nav">
                          <a href="{{ route('posts.display', $post->next->slug) }}" rel="next">
                              <span>@lang('Next One')</span>
                              {{ $post->next->title }}
                          </a>
                      </div>
                  @endisset
               </div>

          </div>
      </article>

  </div>
</div>

@endsection

Normalement un article doit s’afficher avec un url du style monblog.ext/posts/slug.

On a bien l’affichage de l’image mais on remarque un souci avec la barre de navigation :

On va s’en occuper dans le layout. Déjà on va rendre la barre opaque si on n’est pas sur la page d’accueil :

<!-- header
================================================== -->
<header class="s-header @unless(currentRoute('home')) s-header--opaque @endunless">

On va aussi prévoir un padding :

<!-- content
================================================== -->
<section class="s-content @if(currentRoute('home')) s-content--no-top-padding @endif">

Maintenant c’est mieux :

On vérifie qu’on a pour l’article l’avatar, le nom, la date, les catégories et les étiquettes :

On a aussi les liens qui fonctionnent vers les articles précédent et suivant :

Et évidemment le titre et le contenu :

Le SEO

Comme on a prévu des information de SEO pour les articles il faut intervenir sur la vue de layout pour les insérer :

<title>{{ isset($post) && $post->seo_title ? $post->seo_title :  config('app.name') }}</title>
<meta name="description" content="{{ isset($post) && $post->meta_description ? $post->meta_description : __(config('app.description')) }}">
<meta name="author" content="{{ isset($post) ? $post->user->name : __(config('app.author')) }}">
@if(isset($post) && $post->meta_keywords)
    <meta name="keywords" content="{{ $post->meta_keywords }}">
@endif

Pour le SEO de la page d’accueil j’ai juste mis les informations dans config.app :

/*
|--------------------------------------------------------------------------
| SEO
|--------------------------------------------------------------------------
*/

'description' => 'The best blog in the world',
'author' => 'The best author',

Les articles par catégorie

Dans le layout on a prévu précédemment les catégories dans le menu :

Pour le moment ça ne fonctionne pas. On commence par ajouter une fonction dans le repository (PostRepository) :

public function getActiveOrderByDateForCategory($nbrPages, $category_slug)
{
    return $this->queryActiveOrderByDate()
                ->whereHas('categories', function ($q) use ($category_slug) {
                    $q->where('categories.slug', $category_slug);
                })->paginate($nbrPages);
}

On crée aussi une fonction dans le contrôleur (PostController) :

use App\Models\Category;

...

public function category(Category $category)
{
    $posts = $this->postRepository->getActiveOrderByDateForCategory($this->nbrPages, $category->slug);
    $title = __('Posts for category ') . '<strong>' . $category->title . '</strong>';

    return view('front.index', compact('posts', 'title'));
}

On crée une route :

Route::name('category')->get('category/{category:slug}', [FrontPostController::class, 'category']);

On ajoute les liens dans le menu du layout :

<li class="has-children">
    <a href="#" title="">@lang('Categories')</a>
    <ul class="sub-menu">
        @foreach($categories as $category)
            <li><a href="{{ route('category', $category->slug) }}">{{ $category->title }}</a></li>
        @endforeach
    </ul>
</li>

Maintenant quand on utilise le menu en choisissant une catégorie on obtient uniquement les articles de cette catégorie :

Il faut aussi ajouter le lien dans la page de l’article (views/front/post) :

<div class="cat-links">
    <span>@lang('In')</span> 
    @foreach ($post->categories as $category)
        <a href="{{ route('category', $category->slug) }}">{{ $category->title }}</a>
    @endforeach
</div>

Les articles par auteur

Quand on clique sur le nom d’un auteur au niveau des bricks ou dans un article on doit obtenir les articles de cet auteur. On commence par ajouter une fonction dans le repository (PostRepository) :

public function getActiveOrderByDateForUser($nbrPages, $user_id)
{
    return $this->queryActiveOrderByDate()
                ->whereHas('user', function ($q) use ($user_id) {
                    $q->where('users.id', $user_id);
                })->paginate($nbrPages);
}

On crée aussi une fonction dans le contrôleur (PostController) :

use App\Models\{ Category, User };

...

public function user(User $user)
{
    $posts = $this->postRepository->getActiveOrderByDateForUser($this->nbrPages, $user->id);
    $title = __('Posts for author ') . '<strong>' . $user->name . '</strong>';

    return view('front.index', compact('posts', 'title'));
}

On crée une route :

Route::name('author')->get('author/{user}', [FrontPostController::class, 'user']);

On renseigne les liens dans les bricks (views/components/front/brick.blade.php) et on en profite pour ajouter les liens vers l’article :

@props(['post'])

<article class="brick entry" data-aos="fade-up">

  <div class="entry__thumb">
      <a href="{{ route('posts.display', $post->slug) }}" class="thumb-link">
          <img src="{{ getImage($post, true) }}" alt="" style="width:100%">
      </a>
  </div>

  <div class="entry__text">
      <div class="entry__header">
          <h1 class="entry__title"><a href="{{ route('posts.display', $post->slug) }}">{{ $post->title }}</a></h1>          
          <div class="entry__meta">
              <span class="byline"">@lang('By:')
                  <span class='author'>
                      <a href="{{ route('author', $post->user->id) }}">{{ $post->user->name }}</a>
                  </span>
              </span>
          </div>
      </div>
      <div class="entry__excerpt">
          <p>{{ $post->excerpt }}</p>
      </div>
      <a class="entry__more-link" href="{{ route('posts.display', $post->slug) }}">@lang('Read More')</a>
  </div>

</article>

Maintenant on a un accès à l’article et on peut obtenir les articles par auteur :

Il faut aussi ajouter le lien dans la page de l’article (views/front/post) :

<div class="byline">
    <span class="bytext">@lang('Posted By')</span>
    <a href="{{ route('author', $post->user->id) }}">{{ $post->user->name }}</a>
</div>

Les articles par étiquette

Quand on clique sur le nom d’une étiquette dans un article on doit obtenir les articles pour cette étiquette. On commence par ajouter une fonction dans le repository (PostRepository) :

public function getActiveOrderByDateForTag($nbrPages, $tag_slug)
{
    return $this->queryActiveOrderByDate()
                ->whereHas('tags', function ($q) use ($tag_slug) {
                    $q->where('tags.slug', $tag_slug);
                })->paginate($nbrPages);
}

On crée aussi une fonction dans le contrôleur (PostController) :

use App\Models\{ Category, User, Tag };

...

public function tag(Tag $tag)
{
    $posts = $this->postRepository->getActiveOrderByDateForTag($this->nbrPages, $tag->slug);
    $title = __('Posts for tag ') . '<strong>' . $tag->tag . '</strong>';

    return view('front.index', compact('posts', 'title'));
}

On crée une route :

Route::name('tag')->get('tag/{tag:slug}', [FrontPostController::class, 'tag']);

Il faut aussi ajouter les liens dans la page de l’article (views/front/post) :

<div class="entry-tags meta-blk">
    <span class="tagtext">@lang('Tags')</span>
    @foreach($post->tags as $tag)
        <a href="{{ route('tag', $tag->slug) }}">{{ $tag->tag }}</a>
    @endforeach
</div>

Maintenant on peut obtenir les articles par étiquette :

La recherche

Notre blog est équipé d’un formulaire de recherche :

On va s’occuper de cette fonction. Les recherches porteront sur le titre, le résumé (excerpt) et le corps (body).

Le repository

On ajoute une fonction dans le repository (PostRepository) :

public function search($n, $search)
{
    return $this->queryActiveOrderByDate()
                ->where(function ($q) use ($search) {
                    $q->where('excerpt', 'like', "%$search%")
                      ->orWhere('body', 'like', "%$search%")
                      ->orWhere('title', 'like', "%$search%");
                })->paginate($n);
}

La validation

Pour la validation on crée une form request :

php artisan make:request Front/SearchRequest

<?php

namespace App\Http\Requests\Front;

use Illuminate\Foundation\Http\FormRequest;

class SearchRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        return [
            'search' => 'required|string|max:100',
        ];
    }
}

Le contrôleur et la route

Dans le contrôleur PostController on ajoute une fonction :

use App\Http\Requests\Front\SearchRequest;

...

public function search(SearchRequest $request)
{
    $search = $request->search;
    $posts = $this->postRepository->search($this->nbrPages, $search);
    $title = __('Posts found with search: ') . '<strong>' . $search . '</strong>';

    return view('front.index', compact('posts', 'title'));
}

Et on crée la route :

Route::prefix('posts')->group(function () {
    ...
    Route::name('posts.search')->get('', [FrontPostController::class, 'search']);
});

Le layout

On ajoute l’action dans le formulaire au niveau du layout :

<form role="search" method="get" class="s-header__search-form" action="{{ Route('posts.search') }}">
    <label>
        <span class="h-screen-reader-text">@lang('Search for:')</span>
        <input id="search" type="search" name="search" class="s-header__search-field" placeholder="@lang('Search for...')" title="@lang('Search for:')" autocomplete="off">
    </label>
    <input type="submit" class="s-header__search-submit" value="Search"> 
</form>

Et maintenant la recherche fonctionne :

 

Conclusion

On a maintenant un blog qui commence à avoir de l’allure ! Dans le prochain article on se penchera sur les commentaires. On va y passer un peu de temps parce que c’est un peu délicat et il va falloir pas mal de codage côté client et des requêtes en Ajax.

Print Friendly, PDF & Email

24 commentaires

  • fabBlab

    Hello,

    Il y a un bug avec la pagination du moteur de recherche.

    Pour tester, on peut obtenir de nombreux résultats en lançant une recherche « lorem ».
    Les liens de la pagination ignorent le paramètre passé par l’url pour la recherche.
    Par exemple l’url de la deuxième page de résultat est : http://127.0.0.1:8000/posts?page=2
    Quand on clique sur un des liens, on reste sur place.

    Du coup, j’ai regardé de plus près le code de la pagination et je ne comprends pas d’où vient le tableau $elements que l’on parcourt : @foreach ($elements as $element)
    Ceci dit, je ne pense pas que l’erreur vienne de là.
    Je n’ai pas trouvé le moyen de régler le problème « proprement ».

    À noter que tout fonctionne normalement lorsque va manuellement vers la page :
    http://127.0.0.1:8000/posts?search=lorem&page=2

    C’est donc les liens de la pagination qui gèrent mal le cas où on vient du moteur de recherche.

    Sinon petite coquille au tout début de l’article :
    « On va utiliser la façade alors on la déclare dans app/config/app.php … »
    À remplacer par : « dans config/app.php ».

  • zitounisd

    Merci beaucoup pour le project , est ce que vous pourriez m’expliquer comment vous avez utiliser une fonction à l’intérieur dans query builder comme:
    « $this->queryActiveOrderByDate()
    ->whereHas(‘categories’, function ($q) use ($category_slug) {
    $q->where(‘categories.slug’, $category_slug);
    })->paginate($nbrPages); »
    Pourquoi vous avez invoquer $q ? pourquoi vous avez écris function($q) $category_slug?

  • Thibaut

    Bonjour BestMomo
    ma question n’ a pas un rapport direct avec le tuto, j’aimerai savoir comment je pourrais structuré ma base de donnée pour pourvoir faire des series comme dans ta blog, j’aimerai avoir un partie Guide qui regrouperait des article par thematique, du genre guide wordpress où je mettrais des artcles regroupés sous forme de serie. avec la possibilité de voir l’article suivantes de la serie et avoir aussi un tableau de matière bref une architecture un peu comme pour toi.
    est ce qu’en ajoutant une column serie_id dans ma table posts et une table serie ou je cree le nom de le serie , le slug et je fais une relation hasMany pour la table serie et un belongsto pour post serai la bonne solution je ne sais pas vraiment.
    merci d’avance!

    • bestmomo

      Bonjour,

      Le routeur permet de faire un lien implicite entre le nom d’un paramètre et celui du modèle associé au moyen du nom de la variable utilisée, par exemple :

      Route::get('/users/{user}', function (User $user) {

      Mais ça ne fonctionne qu’avec l’id. On dispose toutefois d’une syntaxe spéciale pour dire qu’on ne se réfère pas à l’id mais à une autre colonne de la table, c’est ce que j’ai utilisé là :

      Route::name('category')->get('category/{category:slug}', [FrontPostController::class, 'category']);

      Pour préciser qu’on ne considère pas la colonne id mais la colonne slug pour retrouver l’enregistrement.

  • riftone07

    Bonjour vous avez utilisé l’affichage non securisé de blade {!! $post->body !!}
    Comment vous gerrez la securité à ce niveau si quelqu’un injecte un script.
    Dans un article precedant vous avez conseillé d’utiliser l’affichage {{ }}

    • bestmomo

      Bonjour,
      Les articles peuvent être composés de code HTML donc on a pas trop le choix pour la syntaxe. Mais les rédacteurs sont identifiés et ne sont pas suspects de sabotage :). Ce qui n’est pas le cas pour les commentaires où la syntaxe utilisée et sécurisée.

  • HK

    Bonjour M0M0, …. et le monde

    Tout d’abord merci pour tout ce partage sur Laravel et en FRANçAIS en plus !
    J’ai un petit soucis concernant l’affichage des articles par catégorie.

    La loop dans l’affichage
    @foreach ($post->categories as $category), ne reflète pas le bon nombre des items dans la collection retournée avec la methode paginate().

    C’est à dire que j’ai bien mes 4 items dans ma collection : Illuminate\Pagination\LengthAwarePaginator
    Mais j’affiche des doubles , si un article est attaché à n categories il apparait n fois sur les pages de chaque categorie.

    J’ai cherché sur la doc de Laravel est ->unique() devrait m’aider, mais avec la pagination je n’ arrive pas à l’implémenter sans casser ton PostRepository (très bien structuré) 🙁

    Je suis un peut perdu pour solutionner ce problème.
    Si quelqu’un à une piste ?

    Merci HK

  • jeromeborg

    Bonjour
    encore un super article, j’ai 2 petites questions
    dans le composant « brick », tu utilises un coup {{ route(‘posts.display’, $post->slug) }} et autre coup
    {{ url(‘posts/’ . $post->slug) }} pour accéder aux posts, vu que c’est pareil, il y a t’il une raison particulière ?

    Dans le routeur, je ne comprend pas la syntaxe de cette route (category:slug) :
    Route::name(‘category’)->get(‘category/{category:slug}’, [FrontPostController::class, ‘category’]);

    • gil

      Bonjour,
      Par défaut, pour toute route de type « model/{truc} », la valeur prise par ‘truc’dans l’URL (…/model/12) sera cherchée dans la colonne Id (id=12) du modèle ‘model’.
      La syntaxe « model/{model:field} » permet de dire contextuellement que le paramètre de l’URL (…/model/tata)ne doit pas être cherché dans la colonne clef « Id » mais la colonne « field ». Ici la colonne slug (slug=’tata’). Il faut que ce soit un champ clef unique.
      On peut le faire de façon contextuelle, comme ici, ou changer la méthode par défaut pour un modèle (en surchargeant getRouteKeyName().

    • bestmomo

      Salut

      En général j’utilise toujours l’helper route pour générer les url, comme il m’arrive de faire des changements au moins je n’ai pas besoin d’aller retoucher mon code. Là je n’ai pas utilisé url de façon intentionnelle et il vaut mieux d’après moi toujours utiliser route sauf cas très particulier. Je changerai ça à l’occasion.

      Pour la deuxième question il y a déjà eu une réponse parfaite 🙂

Laisser un commentaire