Pour le moment nous n’avons manipulé qu’une table avec Eloquent. Dans le présent chapitre nous allons utiliser deux tables et les mettre en relation.

La relation la plus répandue et la plus simple entre deux tables est celle qui fait correspondre un enregistrement d’une table à plusieurs enregistrements de l’autre table, on parle de relation de un à plusieurs ou encore de relation de type 1:n.

Il vous faut l’application d’exemple installée et fonctionnelle pour ce chapitre parce que nous allons considérer deux de ses tables et donc de ses modèles. On a les tables users et posts, la première mémorise les utilisateurs et la seconde les articles. Il y a un lien évident entre les deux parce que ce sont des utilisateurs qui rédigent des articles.

Les migrations

La table users

Voici la migration pour la table users :

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;

class CreateUsersTable extends Migration {

  public function up()
  {
    Schema::create('users', function(Blueprint $table) {
      $table->increments('id');
      $table->timestamps();
      $table->string('name')->unique();
      $table->string('email')->unique();
      $table->string('password');
      $table->rememberToken();
      $table->enum('role', array('user', 'redac', 'admin'));
      $table->boolean('valid')->default(false);
    });
  }

  public function down()
  {
    Schema::drop('users');
  }
}

On retrouve la trame de base de la migration d’origine de Laravel avec ces ajouts :

$table->enum('role', array('user', 'redac', 'admin'));
$table->boolean('valid')->default(false);

La colonne role de tyme enum a pour but de définir le rôle de l’utilisateur qui peut être :

  • administrateur : admin (toutes les autorisations)
  • rédacteur : redac (peut rédiger des articles)
  • utilisateur : user (peut rédiger des commentaires)

La colonne valid est de tyle booléen et sert à définir si un utilisateur est correct pour les commentaires. Autrement dit le premier commentaire d’un utilisateur doit être validé par un administrateur, ce qui a pour effet de mettre cette colonne à true. Les commentaires suivants sont directement visibles.

Si vous regardez la table dans la base vous trouverez deux autres colonnes : confirmed et confirmation_code. Ces deux colonnes sont générées par une migration du package laravel-email-confirmation qui se trouve dans le vendor.

La table posts

Voici la migration de la table posts :

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;

class CreatePostsTable extends Migration {

  public function up()
  {
    Schema::create('posts', function(Blueprint $table) {
      $table->increments('id');
      $table->timestamps();
      $table->string('title');
      $table->string('slug')->unique();
      $table->string('seo_title')->nullable();
      $table->text('excerpt');
      $table->text('body');
      $table->text('meta_description');
      $table->text('meta_keywords');
      $table->boolean('active')->default(false);
      $table->integer('user_id')->unsigned();
      $table->string('image')->nullable();
    });
  }

  public function down()
  {
    Schema::drop('posts');
  }
}

Il y a de nombreuses colonnes :

  • l’identifiant,
  • les dates,
  • le titre,
  • le slug,
  • le titre SEO,
  • le texte d’introduction (excerpt),
  • le corps du texte (body),
  • les metas (meta_description et meta_keywords),
  • le statut (active),
  • l’url de l’image (image),

Et la colonne qui va avoir toute son importance pour ce chapitre : user_id, autrement dit l’identifiant de l’utilisateur qui a rédigé l’article. C’est ainsi que les deux tables vont être en relation.

La migration des clés étrangères

La définition des clés étrangères sont toutes dans le fichier database/migrations/ 2017_03_18_145916_create_foreign_keys.php. Voici la partie qui nous intéresse :

Schema::table('posts', function(Blueprint $table) {
    $table->foreign('user_id')->references('id')->on('users')
                ->onDelete('restrict')
                ->onUpdate('restrict');
});

Dans la table posts on déclare une clé étrangère (foreign) nommée user_id qui référence (references) la colonne id dans la table (on) users. En cas de suppression (onDelete) ou de modification (onUpdate) on a une restriction (restrict).

Que signifient ces deux dernières conditions ?

Imaginez que vous avez un utilisateur avec l’id 5 qui a deux articles, donc dans la table posts on a deux enregistrements avec user_id qui a la valeur 5. Si on supprime l’utilisateur que va-t-il se passer ? On risque de se retrouver avec nos deux enregistrements dans la table posts avec une clé étrangère qui ne correspond à aucun enregistrement dans la table users. En mettant restrict on empêche la suppression d’un utilisateur qui a des articles. On doit donc commencer par supprimer ses articles avant de le supprimer lui-même. On dit que la base assure l’intégrité référentielle. Elle n’acceptera pas non plus qu’on utilise pour user_id une valeur qui n’existe pas dans la table users.

Une autre possibilité est cascade à la place de restrict. Dans ce cas si vous supprimez un utilisateur ça supprimera en cascade les articles de cet utilisateur.

C’est une option qui est rarement utilisée parce qu’elle peut s’avérer dangereuse, surtout dans une base comportant de multiples tables en relation. Mais c’est aussi une stratégie très efficace parce que c’est le moteur de la base de données qui se charge de gérer les enregistrements en relation, vous n’avez ainsi pas à vous en soucier au niveau du code.

On pourrait aussi ne pas signaler à la base qu’il existe une relation et la gérer seulement dans notre code. Mais c’est encore plus dangereux parce que la moindre erreur de gestion des enregistrements dans votre code risque d’avoir des conséquences importantes dans votre base avec de multiples incohérences.

La relation

On a la situation suivante :

  • un utilisateur peut écrire plusieurs articles,
  • un article est écrit par un seul utilisateur.

Il faut trouver un moyen pour référencer cette relation dans les tables. Le principe est simple et on l’a vu dans les migrations : on prévoit dans la table posts une colonne destinée à recevoir l’identifiant de l’utilisateur rédacteur de l’article. On appelle cette ligne une clé étrangère parce qu’on enregistre ici la clé d’une autre table. Voici une représentation visuelle de cette relation :

Vous voyez la relation posts_users_id_foreign dessinée entre la clé id dans la table users et la clé étrangère user_id dans la table posts.

Les modèles et la relation

Le modèle User

Le modèle User se trouve ici :

Il comporte en particulier ce code :

public function posts()
{
    return $this->hasMany(Post::class);
}

On déclare ici avec la méthode posts (au pluriel) qu’un utilisateur a plusieurs (hasMany) articles (Post). On aura ainsi une méthode pratique pour récupérer les articles d’un utilisateur.

Le modèle Post

Le modèle Post se trouve ici :

Il comporte en particulier ce code :

public function user()
{
    return $this->belongsTo(User::class);
}

Ici on a la méthode user (au singulier) qui permet de trouver l’utilisateur auquel appartient (belongsTo) l’article. C’est donc la réciproque de la méthode précédente.

La relation 1:n

Voici une schématisation de la relation avec les deux méthodes :

Si vous ne spécifiez pas de manière explicite le nom de la table dans un modèle, Laravel le déduit à partir du nom du modèle en le mettant au pluriel (à la mode anglaise) et en mettant la première lettre en minuscule. Donc avec le modèle Post il en conclut que la table s’appelle posts. Si ce n’était pas satisfaisant il faudrait créer une propriété $table.

Les deux méthodes mises en place permettent de récupérer facilement un enregistrement lié. Par exemple pour avoir tous les articles de l’utilisateur qui a l’id 1 :

$articles = App\Models\User::find(1)->posts;

De la même manière on peut trouver l’utilisateur qui a écrit l’article d’id 1 :

$user = App\Models\Post::find(1)->user;

Vous voyez que le codage devient limpide avec ces méthodes.

Jouer avec la relation

On va voir maintenant ce qu’on peut faire avec cette relation…

Pour changer un peu on ne va pas utiliser Tinker mais entrer directement du code dans le contrôleur Font/PostController, plus exactement dans la méthode index :

public function index()
{
    $posts = $this->postRepository->getActiveOrderByDate($this->nbrPages);

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

Vous allez commenter les deux lignes de code et ça va constituer notre terrain de jeu. C’est la méthode qui est appelée avec la route de base de l’application.

Entrez ce code à la place :

$users = \App\Models\User::take(2)->get();

foreach($users as $user) {
    echo '<strong>' . $user->name . '</strong><br>';
    foreach($user->posts as $post) {
        echo $post->title . '<br>';
    }
}

En lançant l’application vous devez obtenir ceci :

GreatAdmin
Post 1
Post 2
GreatRedactor
Post 3
Post 4
Post 5
Post 6
Post 7
Post 8
Post 9
Post 10

Remarquez la méthode take qui permet de limiter le nombre d’enregistrements retournés.

On obtient le nom des deux premiers utilisateurs et la liste des titres des articles qu’ils ont rédigés. On utilise la relation avec ce code :

foreach($user->posts as $post) {

Autrement dit pour chaque utilisateur on va aller chercher les articles qu’il a écrit, donc on a un accès à la base. Regardons les requêtes générées avec la barre de débogage :

select * from `users` limit 2
select * from `posts` where `posts`.`user_id` = '1' and `posts`.`user_id` is not null
select * from `posts` where `posts`.`user_id` = '2' and `posts`.`user_id` is not null

Si on a beaucoup d’utilisateurs à afficher ça va devenir gênant en terme de nombre d’accès à la base mais Laravel a une solution !

L’eager loading

Dans l’exemple qu’on a vu ci-dessus on parle de lazy loading, de chargement paresseux, parce qu’on accède à la base au moment où on en a besoin. Mais la bonne idée serait plutôt d’anticiper et de tout charger avec une seule requête et ensuite d’itérer dans la collection. Voici la solution :

$users = \App\Models\User::with('posts')->take(2)->get();

On charge les utilisateurs avec (with) les articles (posts). On ne voit le changement qu’au niveau des requêtes :

select * from `users` limit 2
select * from `posts` where `posts`.`user_id` in ('1', '2')

Cette fois on n’en a que deux. La deuxième charge les articles des deux utilisateurs sélectionnés.

On peut aussi ajouter une contrainte sur la relation chargée avec une closure, par exemple :

$users = \App\Models\User::with(['posts' => function ($query) {
    $query->latest('title');
}])->take(2)->get();

Ici on impose un classement aux articles :

GreatAdmin
Post 2
Post 1
GreatRedactor
Post 9
Post 8
Post 7
Post 6
Post 5
Post 4
Post 3
Post 10

Tester la présence de la relation

Des fois on a envie de savoir s’il y a des enregistrements en relation. Par exemple dans notre cas si les utilisateurs ont écrit un article. Voici la syntaxe à utiliser avec la méthode has :

$users = \App\Models\User::has('posts')->get();

foreach($users as $user) {
    echo $user->name . '<br>';
}

Avec ce résultat :

GreatAdmin
GreatRedactor

Les seuls utilisateurs a avoir écrit au moins un article.

On peut avoir plus de latitude dans ce filtrage en utilisant une closure avec la méthode whereHas :

$users = \App\Models\User::whereHas('posts', function ($query) {
    $query->where('title', 'like', '%5%');
})->get()

Là on n’a plus qu’un seul utilisateur :

GreatRedactor

On a les réciproques de ces deux méthodes avec doesntHave (pour has) et whereDoesntHave (pour whereHas).

Compter les enregistrements en relation

On peut aussi facilement compter les enregistrements en relation avec la méthode withCount :

$user = \App\Models\User::withCount('posts')->find(1);

echo $user->name . ' a écrit ' . $user->posts_count . ' articles.';

Avec ce résultat :

GreatAdmin a écrit 2 articles.

On peut ajouter des contraintes avec une closure si on en a besoin, de la même manière qu’on a vue avec with.

L’application d’exemple

Affichage d’un article

Dans l’application d’exemple on n’utilise pas la relation dans le sens User vers (hasMany) Post. Il n’y a aucune page où on liste les articles par utilisateur. Par contre la relation inverse, dans le sens Post vers (belongsTo) User est utilisée. Lorsqu’on sélectionne un article dans la page d’accueil pour l’afficher on a besoin d’aller chercher les informations de l’article ainsi que celles de l’utilisateur qui l’a rédigé. On a besoin aussi d’un certain nombre d’autres informations comme les commentaires, les tags, les catégories, mais nous verrons ces éléments là dans d’autres chapitres. Le nom de l’auteur figure en bas de l’article :

Les articles sont sélectionnés par leur slug. Par exemple le huitième article a comme url …/posts/post-8. Le slug est unique à l’article et doit normalement évoquer de façon sémantique son contenu et être facile à digérer pour les moteurs de recherche. Comme il figure dans l’url il ne doit comporter aucun caractère « interdit ».

Dans le fichier PostRepository on a la fonction getPostBySlug qui a justement pour mission de récupérer toutes les informations nécessaires à l’affichage d’un article à partir de son slug :

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 peut remarquer le chargement des informations de l’utilisateur (eager loading) :

$post = $this->model->with([
    'user' => function ($q) {
        $q->select('id', 'name', 'email');

On a une contrainte concernant les colonnes retournées, on ne prend que les 3 qui nous intéressent. Si vous regardez dans la barre de débogage vous trouverez cette requête :

select `id`, `name`, `email` from `users` where `users`.`id` in ('2')

D’autre part avec ce code :

->whereSlug($slug)
->firstOrFail();

On fait la recherche par le slug. On a une formulation « magique » équivalent à celle-ci :

->where('slug', $slug)

La méthode firstOrFail est équivalent à la méthode first à la différence que si aucun enregistrement n’est trouvé on aura la génération d’une erreur 404.

La route pour l’affichage d’un article est là :

Route::prefix('posts')->namespace('Front')->group(function () {
    Route::name('posts.display')->get('{slug}', 'PostController@show');
    ...
});

Quelques remarques sur la syntaxe de ce groupe de routes :

  • prefix : toutes les routes seront préfixées avec posts,
  • namespace : les noms des contrôleurs seront dans cet espace de nom (Front) sans avoir besoin de le préciser,

La route apparait ainsi avec Artisan :

On arrive donc sur la méthode show du contrôleur Front/PostController :

public function show(Request $request, $slug)
{
    $user = $request->user();

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

On voit l’appel à la méthode du repository qu’on a vue plus haut (getPostBySlug). On transmet également l’utilisateur en cours de visite ($request->user()), mais c’est pour les commentaires et on verra ça dans un autre chapitre.

La liste des articles dans l’administration

Un administrateur ou un rédacteur (mais uniquement pour ses propres articles) peut avoir la liste des articles dans l’administration :

Dans le repository PostRepository on a la fonction getAll qui, comme son nom l’indique, doit récupérer tous les articles (mais quand même paginés) :

public function getAll($nbrPages, $parameters)
{
    return $this->model->with ('ingoing')
        ->orderBy ($parameters['order'], $parameters['direction'])
        ->when ($parameters['active'], function ($query) {
            $query->whereActive (true);
        })->when ($parameters['new'], function ($query) {
            $query->has ('ingoing');
        })->when (auth()->user()->role === 'redac', function ($query) {
            $query->whereHas('user', function ($query) {
                $query->where('users.id', auth()->id());
            });
        })->paginate ($nbrPages);
}

Voici la version épurée pour ne garder que ce qui nous intéresse pour ce chapitre :

return $this->model->with ('ingoing')
    ...
    })->when (auth()->user()->role === 'redac', function ($query) {
        $query->whereHas('user', function ($query) {
            $query->where('users.id', auth()->id());
        });
    ...

La méthode when est très pratique parce qu’elle permet d’ajouter des éléments à une requête selon la situation. Ici on dit que quand (when) le rôle de l’utilisateur actuel auth()->user()->role est redac (donc c’est un rédacteur) alors on filtre les articles avec whereHas sur la relation avec users, en ne gardant que les siens : $query->where(‘users.id’, auth()->id()).

En effet on veut qu’un rédacteur n’ait accès qu’à ses propres articles dans la partie administration. On verra qu’il faut prendre d’autres précautions plus tard.

Si vous cherchez comment cette fonction est appelée à partir du contrôle Back/PostController vous allez un peu vous promener parce que la méthode index concernée est dans le trait Indexable :

public function index(Request $request)
{
    $parameters = $this->getParameters ($request);

    // Get records and generate links for pagination
    $records = $this->repository->getAll (config ("app.nbrPages.back.$this->table"), $parameters);
    $links = $records->appends ($parameters)->links ('back.pagination');

    // Ajax response
    if ($request->ajax ()) {
        return response ()->json ([
            'table' => view ("back.$this->table.table", [$this->table => $records])->render (),
            'pagination' => $links->toHtml (),
        ]);
    }

    return view ("back.$this->table.index", [$this->table => $records, 'links' => $links]);
}

En effet cette méthode est commune à plusieurs contrôleurs et l’affichage des articles dépend de la sélection des nouveaux articles ou des articles actifs :

Par exemple avec l’url …/admin/posts?new=on on veut seulement les nouveaux articles. C’est pour cette raison que dans la méthode index on récupère les paramètres de la requête HTTP et qu’on les transmet au repository :

$records = $this->repository->getAll (config ("app.nbrPages.back.$this->table"), $parameters);

Dans le repository on en tient compte pour construire la requête :

->when ($parameters['active'], function ($query) {
    $query->whereActive (true);
})->when ($parameters['new'], function ($query) {
    $query->has ('ingoing');

Mais je reparlerai de tout ça dans un prochain chapitre !

En résumé

  • Une relation de type 1:n nécessite la création d’une clé étrangère côté n.
  • Une relation dans la base nécessite la mise en place de méthodes spéciales dans les modèles.
  • On dispose de plusieurs méthodes pour manipuler une relation.

Laisser un commentaire