
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.


30 commentaires
lafia
Bonjour
J’aimerais savoir votre logique concernant le repository. En effet, vous ne l’utilisez pas pour tous vos controllers. Après tout, shematiquement comment est l’architecture quand on utilise un repo avec MVC ?
merci
bestmomo
Bonjour,
Dans la théorie un repository est là pour éviter la dépendance avec la couche de données. Si on sait qu’on utilisera toujours Eloquent cette raison de base n’existe plus. Personnellement je prends plutôt le repository comme une façon de mieux organiser le code. Des méthodes peuvent être appelées des contrôleurs ou d’ailleurs. J’essaie de garder les contrôleurs les plus légers possible.
azizoux
Bonjour,
je trouve cet erreur que je n’arrive pas debugguer:
Illuminate\Contracts\Container\BindingResolutionException
Target class [App\Http\Controllers\Front\Category] does not exist.
http://localhost:8000/category/category-1
bestmomo
Bonjour,
Essaie de régénérer le chargement des classes avec un composer dumpautolaod.
azizoux
Bonsoir,
Malheuresement l’erreur persiste malgrés la regeneration avec composer dumpautoload.
PS C:\Users\user\Documents\xampp\htdocs\monblog> composer dumpautoload
Generating optimized autoload files
> Illuminate\Foundation\ComposerScripts::postAutoloadDump
> @php artisan package:discover –ansi
Discovered Package: creativeorange/gravatar
Discovered Package: facade/ignition
Discovered Package: fruitcake/laravel-cors
Discovered Package: intervention/image
Discovered Package: kalnoy/nestedset
Discovered Package: laravel/breeze
Discovered Package: laravel/sail
Discovered Package: laravel/sanctum
Discovered Package: laravel/tinker
Discovered Package: nesbot/carbon
Discovered Package: nunomaduro/collision
Discovered Package: unisharp/laravel-filemanager
Package manifest generated successfully.
Generated optimized autoload files containing 5042 classes
azizoux
Bonjour Bestmomo,
j’ai pu resoudre le probleme. c’est effectivement un probleme de rechargement que j’ai pu debuguer avec la function dd();
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?
bestmomo
Bonjour,
Globalement on gère une requête, dans le whereHas on a besoin de la requête actuelle pour la compléter par une condition, c’est pour cette raison qu’elle est passée en paramètre.
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
Salut,
Oui dans ton cas, il me semble qu’une simple relation 1:n est satisfaisante avec une table supplémentaire pour mémoriser les séries.
bensa
Bonjour,
Merci de m’expliquer le focntionnement de cette déclaration dans le routeur pour les postes par catégorie:
{category:slug}
Merci infinimement
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.
bensa
C’est trés claire maintenant, merci beaucoup pour votre réponse.
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
bestmomo
Salut,
Je ne suis pas sûr de bien comprendre le problème. Dans l’affichage des articles par catégorie on ne devrait pas avoir de doublon sauf si un article a été affecté deux fois à une catégorie ce qui normalement ne devrait pas se produire.
HK
Merci M0m0 pour ce retour,
j’avais 2 foreach dans mon component d’article => donc affichage en double 🙁
C’était donc un rendu normal car mal codé !
Michel
Bonjour,
Merci pour vos retours à mes demandes précédentes qui m’ont encouragé à pousuivre.
A la fin de ce tuto, avec monblog.test/posts/slug, j’obtiens une page avec l’erreur: 404 I not found
Ai-je manqué quelque chose ?
bestmomo
Salut,
Le slug doit correspondre à un existant dans la base, par exemple monblog.test/posts/post-1
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().
jeromeborg
Merci de votre réponse, c’est vrai qu’en mode « implicite » c’est l id par défaut
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 🙂
jeromeborg
Merci de ta réponse, je m’en doutais pour l’url
bestmomo
J’ai corrigé dans l’article, je vais faire suivre les ZIP.
oksam
Bonjour Best! c’est cool c’est résolu! merci pour la réactivité!
oksam
Bonsoir Best, le changement de la page d’acceuil, l’affiche de l’avatar et des posts ne s’affiche, il n’y a aucune erreur apparente et l’inspecteur ne renvoi aucune erreur également.
bestmomo
Salut,
Pas facile de trouver d’où ça vient du coup… Est-ce une page blanche ? En utilisant l’inspecteur du navigateur est-ce qu’il y a du code ? Au niveau de l’onglet réseau il passe quoi ?