• 15 heures
  • Moyenne

Ce cours est visible gratuitement en ligne.

course.header.alt.is_certifying

J'ai tout compris !

Mis à jour le 11/05/2020

La relation n:n

Connectez-vous ou inscrivez-vous gratuitement pour bénéficier de toutes les fonctionnalités de ce cours !

Dans le précédent chapitre nous avons vu la relation de type 1:n, la plus simple et la plus répandue. Nous allons maintenant étudier la relation de type n:n, plus délicate à comprendre et à mettre en œuvre. Nous allons voir qu'Eloquent permet de simplifier la gestion de ce type de relation.

Je vais poursuivre l'exemple du blog personnel débuté au chapitre précédent en ajoutant la possibilité d'ajouter des mots-clés (tags) aux articles. Ce chapitre est un peu long mais j'ai préféré tout rassembler ici.

Les données

La relation n:n

Imaginez une relation entre deux tables A et B qui permet de dire : 

  • je peux avoir une ligne de la table A en relation avec plusieurs lignes de la table B,

  • je peux avoir une ligne de la table B en relation avec plusieurs lignes de la table A.

Cette relation ne se résout pas comme nous l'avons vu au chapitre précédent avec une simple clé étrangère dans une des tables. En effet il nous faudrait des clés dans les deux tables et plusieurs clés, ce qui n'est pas possible à réaliser. La solution consiste à créer une table intermédiaire (nommée table pivot) qui sert à mémoriser les clés étrangères. Voici un schéma de ce que nous allons réaliser :

La table pivot

La table pivot  post_tag contient les clés des deux tables : 

  • post_id pour mémoriser la clé de la table posts,

  • tag_id pour mémoriser la clé de la table tags.

De cette façon on peut avoir plusieurs enregistrements liés entre les deux tables, il suffit à chaque fois d'enregistrer les deux clés dans la table pivot. Évidemment au niveau du code ça demande un peu d'intendance parce qu'il y a une table supplémentaire à gérer.

Les migrations

Nous allons continuer à utiliser les tables  users et  posts que nous avons vues aux chapitres précédents. Nous allons créer une nouvelle table  tags destinée à mémoriser les mots-clés. Commencez par supprimer toutes les tables de votre base de données, sinon vous risquez de tomber sur des conflits avec les enregistrements que nous allons créer.

Normalement vous devez déjà disposer des migrations pour les tables  users,  password_resets et  posts. Nous allons ajouter les deux tables :  tags et  post_tag.

Créez une nouvelle migration pour la table  tags :

php artisan make:migration create_tags_table

Et entrez ce code :

<?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateTagsTable extends Migration {
public function up()
{
Schema::create('tags', function(Blueprint $table) {
$table->increments('id');
$table->timestamps();
$table->string('tag', 50)->unique();
$table->string('tag_url', 60)->unique();
});
}
public function down()
{
Schema::drop('tags');
}
}

On prévoit les champs :

  • id : clé unique incrémentée,

  • created_at et updated_at créées par timestamps,

  • tag : le mot clé unique limité à 50 caractères,

  • tag_url : la version du tag à inclure dans l'url (avec 60 comme limite pour couvrir les cas les plus défavorables).

Il nous faut deux champs pour le tag, en effet il va falloir qu'on le transmette dans l'url pour la recherche par tag, or l'utilisateur risque de rentrer des accents par exemple (ou pire des "/"), nous allons convertir ces caractères spéciaux en caractères adaptés aux urls.

Créez une nouvelle migration pour la table  post_tag :

php artisan make:migration create_post_tag_table

Et entrez ce code :

<?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreatePostTagTable extends Migration {
public function up()
{
Schema::create('post_tag', function(Blueprint $table) {
$table->increments('id');
$table->integer('post_id')->unsigned();
$table->integer('tag_id')->unsigned();
$table->foreign('post_id')->references('id')->on('posts')
->onDelete('restrict')
->onUpdate('restrict');
$table->foreign('tag_id')->references('id')->on('tags')
->onDelete('restrict')
->onUpdate('restrict');
});
}
public function down()
{
Schema::table('post_tag', function(Blueprint $table) {
$table->dropForeign('post_tag_post_id_foreign');
$table->dropForeign('post_tag_tag_id_foreign');
});
Schema::drop('post_tag');
}
}

On prévoit les champs :

  • post_id : clé étrangère pour la table posts,

  • tag_id : clé étrangère pour la table tags.

J'ai encore prévu l'option "restrict" pour les cascades pour sécuriser les opérations sur la base.

Normalement vous devez avoir ces migrations :

Les migrations

Lancez les migrations :

php artisan migrate

Vous devez ainsi vous retrouver avec ces 6 tables dans votre base  :

Les tables

La population

Vous avez déjà les fichiers pour les tables  users et  posts. On va créer celui pour les tags (TagTableSeeder.php) :

<?php
use Illuminate\Database\Seeder;
use Carbon\Carbon;
class TagTableSeeder extends Seeder {
private function randDate()
{
return Carbon::createFromDate(null, rand(1, 12), rand(1, 28));
}
public function run()
{
DB::table('tags')->delete();
for($i = 0; $i < 20; ++$i)
{
$date = $this->randDate();
DB::table('tags')->insert(array(
'tag' => 'tag' . $i,
'tag_url' => 'tag' . $i,
'created_at' => $date,
'updated_at' => $date
));
}
}
}

On aura ainsi 20 tags.

On crée aussi le fichier pour la table pivot (PostTagTableSeeder.php) :

<?php
use Illuminate\Database\Seeder;
class PostTagTableSeeder extends Seeder {
public function run()
{
for($i = 1; $i <= 100; ++$i)
{
$numbers = range(1, 20);
shuffle($numbers);
$n = rand(3, 6);
for($j = 1; $j < $n; ++$j)
{
DB::table('post_tag')->insert(array(
'post_id' => $i,
'tag_id' => $numbers[$j]
));
}
}
}
}

De façon aléatoire on crée plusieurs affectations de tags aux articles.

Il ne reste plus qu'à mettre à jour le fichier DatabaseSeeder.php :

<?php
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
$this->call(UserTableSeeder::class);
$this->call(PostTableSeeder::class);
$this->call(TagTableSeeder::class);
$this->call(PostTagTableSeeder::class);
}
}

Vous devez avoir ces fichiers :

Les fichiers de population

Il ne reste plus qu'à lancer la population :

php artisan db:seed

Les modèles

On va avoir besoin de déclarer la relation n:n dans le modèle  Post :

<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
protected $fillable = ['titre','contenu','user_id'];
public function user()
{
return $this->belongsTo('App\User');
}
public function tags()
{
return $this->belongsToMany('App\Tag');
}
}

La méthode  tags permettra de récupérer les tags qui sont en relation avec l'article. On utilise la méthode  belongsToMany d'Eloquent pour le faire.

On va aussi avoir besoin d'un modèle pour les tags :

<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Tag extends Model
{
protected $fillable = ['tag','tag_url'];
public function posts()
{
return $this->belongsToMany('App\Post');
}
}

On a la méthode réciproque de la précédente :  posts permettra de récupérer les articles en relation avec le tag.

Voici une schématisation de cette relation avec les deux méthodes symétriques :

La relation n:n
La relation n:n

On se retrouve avec ces trois modèles :

Les 3 modèles
Les 3 modèles

La validation

Nous allons avoir un cas de validation un peu particulier. En effet comme je l'ai dit ci-dessus les tags vont être entrés dans un contrôle de texte séparés par des virgules. On a prévu dans la table  tags qu'ils ne devraient pas dépasser 50 caractères. On ne dispose pas dans l'arsenal des règles de validation de Laravel d'une telle possibilité, il va donc falloir la créer.

On a déjà créé une classe  PostRequest dans le chapitre précédent, il faut ajouter la règle pour les tags :

<?php
namespace App\Http\Requests;
use App\Http\Requests\Request;
class PostRequest extends Request
{
/**
* 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 [
'titre' => 'required|max:80',
'contenu' => 'required',
'tags' => ['Regex:/^[A-Za-z0-9-éèàù]{1,50}?(,[A-Za-z0-9-éèàù]{1,50})*$/']
];
}
}

Comme le cas est particulier j'ai utilisé une expression rationnelle. Il ne reste plus qu'à traiter le message.

Si vous regardez dans le fichier  resources/lang/en/validation.php vous trouvez ce code :

<?php
'custom' => [
'attribute-name' => [
'rule-name' => 'custom-message',
],
],

C'est ici qu'on peut ajouter des messages spécifiques. On va donc écrire :

<?php
'custom' => [
'tags' => [
'regex' => "tags, separated by commas (no spaces), should have a maximum of 50 characters.",
],
],

On va faire la même chose dans le fichier du Français :

<?php
'custom' => [
'tags' => [
'regex' => "Les mots-clefs, séparés par des virgules (sans espaces), doivent avoir au maximum 50 caractères alphanumériques.",
],
],

La gestion

Le contrôleur et les routes

Maintenant que tout est en place au niveau des données et de la validation voyons un peu la gestion de tout ça. On a déjà un contrôleur  PostController mais on doit le compléter pour le fonctionnement avec les tags :

<?php
namespace App\Http\Controllers;
use App\Repositories\PostRepository;
use App\Repositories\TagRepository;
use App\Http\Requests\PostRequest;
class PostController extends Controller
{
protected $postRepository;
protected $nbrPerPage = 4;
public function __construct(PostRepository $postRepository)
{
$this->middleware('auth', ['except' => ['index', 'indexTag']]);
$this->middleware('admin', ['only' => 'destroy']);
$this->postRepository = $postRepository;
}
public function index()
{
$posts = $this->postRepository->getWithUserAndTagsPaginate($this->nbrPerPage);
$links = $posts->render();
return view('posts.liste', compact('posts', 'links'));
}
public function create()
{
return view('posts.add');
}
public function store(PostRequest $request, TagRepository $tagRepository)
{
$inputs = array_merge($request->all(), ['user_id' => $request->user()->id]);
$post = $this->postRepository->store($inputs);
if(isset($inputs['tags']))
{
$tagRepository->store($post, $inputs['tags']);
}
return redirect(route('post.index'));
}
public function destroy($id)
{
$this->postRepository->destroy($id);
return redirect()->back();
}
public function indexTag($tag)
{
$posts = $this->postRepository->getWithUserAndTagsForTagPaginate($tag, $this->nbrPerPage);
$links = $posts->render();
return view('posts.liste', compact('posts', 'links'))
->with('info', 'Résultats pour la recherche du mot-clé : ' . $tag);
}
}

J'ai ajouté la méthode  indexTag qui doit lancer la recherche des articles qui comportent ce tag et envoyer les informations dans la vue  liste. J'ai aussi un peu remanié le code. 

Il faut ajouter la route pour aboutir sur cette nouvelle méthode :

<?php
Route::resource('post', 'PostController', ['except' => ['show', 'edit', 'update']]);
Route::get('post/tag/{tag}', 'PostController@indexTag');

Les repositories

Voici le repository pour les articles (app/Repositories/PostRepository.php) modifié pour tenir compte des tags :

<?php namespace App\Repositories;
use App\Post;
class PostRepository {
protected $post;
public function __construct(Post $post)
{
$this->post = $post;
}
private function queryWithUserAndTags()
{
return $this->post->with('user', 'tags')
->orderBy('posts.created_at', 'desc');
}
public function getWithUserAndTagsPaginate($n)
{
return $this->queryWithUserAndTags()->paginate($n);
}
public function getWithUserAndTagsForTagPaginate($tag, $n)
{
return $this->queryWithUserAndTags()
->whereHas('tags', function($q) use ($tag)
{
$q->where('tags.tag_url', $tag);
})->paginate($n);
}
public function store($inputs)
{
return $this->post->create($inputs);
}
public function destroy($id)
{
$post = $this->post->findOrFail($id);
$post->tags()->detach();
$post->delete();
}
}

Et voici le repository pour les tags (app/Repositories/TagRepository.php‌) :

<?php
namespace App\Repositories;
use App\Tag;
use Illuminate\Support\Str;
class TagRepository
{
protected $tag;
public function __construct(Tag $tag)
{
$this->tag = $tag;
}
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);
}
}
}
}

Fonctionnement

Nous allons à présent analyser ce code.

La liste des articles

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

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

Pour clarifier le code j'ai créé une fonction privée qui va nous servir plusieurs fois :

<?php
private function queryWithUserAndTags()
{
return $this->post->with('user', 'tags')
->orderBy('posts.created_at', 'desc');
}

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 `posts`.`created_at` desc limit 4 offset 0
select * from `users` where `users`.`id` in ('7', '2', '6')
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 ('25', '1', '18', '14')

On voit que :

  1. on demande le nombre total d'articles pour la pagination,

  2. on demande les 4 premières lignes des articles avec l'ordre des dates,

  3. on demande les utilisateurs qui correspondent aux articles sélectionnés,

  4. 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 posts on va se contenter d'enregistrer l'article :

<?php
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 :

<?php
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) :

<?php
$tags = explode(',', $tags);

Ensuite on parcourt le tableau :

<?php
foreach ($tags as $tag)

Par précaution on supprime les espaces éventuels  :

<?php
$tag = trim($tag);

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

<?php
$tag_url = Str::slug($tag);

On regarde si ce tag existe déjà :

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

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

<?php
$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 :

<?php
$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 :

<?php
public function destroy($id)
{
$post = $this->post->findOrFail($id);
$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 :

<?php
public function getWithUserAndTagsForTagPaginate($tag, $n)
{
return $this->queryWithUserAndTags()
->whereHas('tags', function($q) use ($tag)
{
$q->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 (select count(*) from `tags` inner join `post_tag` on `tags`.`id` = `post_tag`.`tag_id` where `post_tag`.`post_id` = `posts`.`id` and `tags`.`tag_url` = 'tag-14') >= '1'
select * from `posts` where (select count(*) from `tags` inner join `post_tag` on `tags`.`id` = `post_tag`.`tag_id` where `post_tag`.`post_id` = `posts`.`id` and `tags`.`tag_url` = 'tag-14') >= '1' order by `posts`.`created_at` desc limit 4 offset 0
select * from `users` where `users`.`id` in ('3', '1', '6', '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 ('13', '76', '87', '37')

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

  1. on compte les enregistrements pour la pagination (avec une jointure),

  2. on récupère les 4 lignes des articles (avec une jointure),

  3. on récupère les utilisateurs rédacteurs des articles,

  4. 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 ou une action de l'administrateur.

Les vues

Le template

On conserve le même template (resources/views/template.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">
<title>Mon joli site</title>
{!! Html::style('https://netdna.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css') !!}
{!! Html::style('https://netdna.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap-theme.min.css') !!}
<!--[if lt IE 9]>
{{ Html::style('https://oss.maxcdn.com/libs/html5shiv/3.7.2/html5shiv.js') }}
{{ Html::style('https://oss.maxcdn.com/libs/respond.js/1.4.2/respond.min.js') }}
<![endif]-->
<style> textarea { resize: none; } </style>
</head>
<body>
<header class="jumbotron">
<div class="container">
<h1 class="page-header">{!! link_to_route('post.index', 'Mon joli blog') !!}</h1>
@yield('header')
</div>
</header>
<div class="container">
@yield('contenu')
</div>
</body>
</html>

La liste

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

@extends('template')
@section('header')
@if(Auth::check())
<div class="btn-group pull-right">
{!! link_to_route('post.create', 'Créer un article', [], ['class' => 'btn btn-info']) !!}
{!! link_to('logout', 'Deconnexion', ['class' => 'btn btn-warning']) !!}
</div>
@else
{!! link_to('login', 'Se connecter', ['class' => 'btn btn-info pull-right']) !!}
@endif
@endsection
@section('contenu')
@if(isset($info))
<div class="row alert alert-info">{{ $info }}</div>
@endif
{!! $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)
{!! link_to('post/tag/' . $tag->tag_url, $tag->tag, ['class' => 'btn btn-xs btn-info']) !!}
@endforeach
</div>
</h1>
</header>
<hr>
<section>
<p>{{ $post->contenu }}</p>
@if(Auth::check() and Auth::user()->admin)
{!! Form::open(['method' => 'DELETE', 'route' => ['post.destroy', $post->id]]) !!}
{!! Form::submit('Supprimer cet article', ['class' => 'btn btn-danger btn-xs ', 'onclick' => 'return confirm(\'Vraiment supprimer cet article ?\')']) !!}
{!! Form::close() !!}
@endif
<em class="pull-right">
<span class="glyphicon glyphicon-pencil"></span> {{ $post->user->name }} le {!! $post->created_at->format('d-m-Y') !!}
</em>
</section>
</div>
</article>
<br>
@endforeach
{!! $links !!}
@endsection

Avec cet aspect :

La liste des articles

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 :

La recherche par tag

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

Les boutons pour les utilisateurs connectés

La vue de 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('template')
@section('contenu')
<br>
<div class="col-sm-offset-3 col-sm-6">
<div class="panel panel-info">
<div class="panel-heading">Ajout d'un article</div>
<div class="panel-body">
{!! Form::open(['route' => 'post.store']) !!}
<div class="form-group {!! $errors->has('titre') ? 'has-error' : '' !!}">
{!! Form::text('titre', null, ['class' => 'form-control', 'placeholder' => 'Titre']) !!}
{!! $errors->first('titre', '<small class="help-block">:message</small>') !!}
</div>
<div class="form-group {!! $errors->has('contenu') ? 'has-error' : '' !!}">
{!! Form::textarea ('contenu', null, ['class' => 'form-control', 'placeholder' => 'Contenu']) !!}
{!! $errors->first('contenu', '<small class="help-block">:message</small>') !!}
</div>
<div class="form-group {{ $errors->has('tags') ? 'has-error' : '' }}">
{!! Form::text('tags', null, array('class' => 'form-control', 'placeholder' => 'Entrez les tags séparés par des virgules')) !!}
{!! $errors->first('tags', '<small class="help-block">:message</small>') !!}
</div>
{!! Form::submit('Envoyer !', ['class' => 'btn btn-info pull-right']) !!}
{!! Form::close() !!}
</div>
</div>
<a href="javascript:history.back()" class="btn btn-primary">
<span class="glyphicon glyphicon-circle-arrow-left"></span> Retour
</a>
</div>
@endsection

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

Le formulaire de création d'un article

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é

  • Une relation de type n:n nécessite la création d'une table pivot.

  • Eloquent gère élégamment les tables pivots avec des méthodes adaptées.

  • On peut créer des règles et des messages de validation personnalisés.

Exemple de certificat de réussite
Exemple de certificat de réussite