Je suis intervenu récemment sur une question dans le forum du site Laracasts. En résumé la question est : comment greffer une relation n:n sur une autre relation n:n. Autrement dit comment faire du n:n entre un pivot et une autre table en permettant de faire facilement des requêtes ?

La question m’a paru suffisamment intéressante pour rédiger cet article parce que ça permet de montrer comment utiliser intelligemment les relations et ne pas se limiter à un seul type de relation dans une situation particulière.

Les relations

La relation n:n de base

A la base on a une relation classique n:n entre une table users et une table roles avec un pivot :

img37

Jusque là pas de souci on a une situation standard qui respecte la normalisation des appellations.

Un utilisateur peut avoir plusieurs rôles et un rôle peut être assumé par plusieurs utilisateurs. D’où la présence de la table pivot qui permet cette double multiplicité.

Au niveau des relations dans le modèle User on a :

public function roles()
{
    return $this->belongsToMany('App\Role');
}

Et le symétrique dans Role :

public function users()
{
    return $this->belongsToMany('App\User');
}

La relation n:n ajoutée

Maintenant on veut ajouter une table tags en relation avec le pivot role_user. Autrement dit un utilisateur pour un certain rôle a plusieurs tags. Pour arriver à réaliser cela il faut :

  • prévoir un modèle pour la table pivot role_user,
  • créer un nouveau pivot.

Voilà ce que ça donne :

img38

On a une relation n:n entre la table role_user et la table tags avec comme pivot role_user_tag.

On peut donc établir les relations correspondantes, dans le modèle RoleUser :

public function tags()
{
    return $this->belongsToMany('App\Tag');
}

Et dans le modèle Tag :

public function role_users()
{
    return $this->belongsToMany('App\RoleUser');
}

On peut donc créer des requêtes entre ces deux tables.

Mais la difficulté qui arrive maintenant est de faire le lien entre les deux relations n:n !

Par exemple comment trouver pour les utilisateurs les rôles ainsi que les tags pour chaque rôle ? Avec les relations établies ci-dessus ce n’est pas possible, du moins pas simplement…

Ajouter des relations

Pour résoudre le problème il faut se rendre compte qu’une relation n:n est en fait une combinaison de deux 1:n.

Si on considère par exemple la relation entre les utilisateurs et les rôles :

img39

A partir du moment où on a un modèle pour la table pivot on peut établir ces relations. En fait on transite par cette table pivot. L’avantage c’est qu’on peut ainsi biffurquer dans une autre direction !

On va donc ajouter la relation hasMany dans User pour se retrouver avec cette situation :

public function roles()
{
    return $this->belongsToMany('App\Role');
}

public function role_users()
{
    return $this->hasMany('App\RoleUser');
}

De la même façon dans Role :

public function users()
{
    return $this->belongsToMany('App\User');
}

public function role_users()
{
    return $this->hasMany('App\RoleUser');
}

Et dans le pivot RoleUser :

public function user()
{
    return $this->belongsTo('App\User');
}

public function role()
{
    return $this->belongsTo('App\Role');
}

public function tags()
{
    return $this->belongsToMany('App\Tag');
}

On a maintenant toutes les relations qu’il nous faut !

Migrations et population

Migrations

Pour faire des essais il faut construire le schéma de la base, voici toutes les migrations :

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');
		});
	}

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

Pour la table roles :

<?php

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

class CreateRoleUserTable extends Migration {

	public function up()
	{
		Schema::create('role_user', function(Blueprint $table) {
			$table->increments('id');
			$table->timestamps();
			$table->integer('user_id')->unsigned();
			$table->integer('role_id')->unsigned();
		});
	}

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

Pour la table tags :

<?php

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

class CreateTagsTable extends Migration {

	public function up()
	{
		Schema::create('tags', function(Blueprint $table) {
			$table->increments('id');
			$table->timestamps();
			$table->string('name');
		});
	}

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

Pour la table role_user :

<?php

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

class CreateRoleUserTable extends Migration {

	public function up()
	{
		Schema::create('role_user', function(Blueprint $table) {
			$table->increments('id');
			$table->timestamps();
			$table->integer('user_id')->unsigned();
			$table->integer('role_id')->unsigned();
		});
	}

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

Pour la table role_user_tag :

<?php

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

class CreateRoleUserTagTable extends Migration {

	public function up()
	{
		Schema::create('role_user_tag', function(Blueprint $table) {
			$table->increments('id');
			$table->timestamps();
			$table->integer('role_user_id')->unsigned();
			$table->integer('tag_id')->unsigned();
		});
	}

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

Et enfin pour les contraintes :

<?php

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

class CreateForeignKeys extends Migration {

	public function up()
	{
		Schema::table('role_user', function(Blueprint $table) {
			$table->foreign('user_id')->references('id')->on('users')
						->onDelete('restrict')
						->onUpdate('restrict');
		});
		Schema::table('role_user', function(Blueprint $table) {
			$table->foreign('role_id')->references('id')->on('roles')
						->onDelete('restrict')
						->onUpdate('restrict');
		});
		Schema::table('role_user_tag', function(Blueprint $table) {
			$table->foreign('role_user_id')->references('id')->on('role_user')
						->onDelete('restrict')
						->onUpdate('restrict');
		});
		Schema::table('role_user_tag', function(Blueprint $table) {
			$table->foreign('tag_id')->references('id')->on('tags')
						->onDelete('restrict')
						->onUpdate('restrict');
		});
	}

	public function down()
	{
		Schema::table('role_user', function(Blueprint $table) {
			$table->dropForeign('role_user_user_id_foreign');
		});
		Schema::table('role_user', function(Blueprint $table) {
			$table->dropForeign('role_user_role_id_foreign');
		});
		Schema::table('role_user_tag', function(Blueprint $table) {
			$table->dropForeign('role_user_tag_role_user_id_foreign');
		});
		Schema::table('role_user_tag', function(Blueprint $table) {
			$table->dropForeign('role_user_tag_tag_id_foreign');
		});
	}
}

Population

Enfin il nous faut des enregistrements :

<?php

use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        DB::table('users')->insert([

            ['name' => 'user1'],
            ['name' => 'user2'],
            ['name' => 'user3'],

        ]);

        DB::table('roles')->insert([

            ['name' => 'role1'],
            ['name' => 'role2'],

        ]);


        DB::table('tags')->insert([

            ['name' => 'tag1'],
            ['name' => 'tag2'],
            ['name' => 'tag3'],

        ]);

        DB::table('role_user')->insert([

            ['user_id' => 1, 'role_id' => 1],
            ['user_id' => 1, 'role_id' => 2],
            ['user_id' => 2, 'role_id' => 1],
            ['user_id' => 3, 'role_id' => 2],

        ]);

        DB::table('role_user_tag')->insert([

            ['role_user_id' => 1, 'tag_id' => 1],
            ['role_user_id' => 1, 'tag_id' => 2],
            ['role_user_id' => 2, 'tag_id' => 2],
            ['role_user_id' => 2, 'tag_id' => 3],
            ['role_user_id' => 3, 'tag_id' => 1],
            ['role_user_id' => 3, 'tag_id' => 2],
            ['role_user_id' => 4, 'tag_id' => 1],
            ['role_user_id' => 4, 'tag_id' => 2],
            ['role_user_id' => 4, 'tag_id' => 3],
            
        ]);

    }
}

Des requêtes

On a maintenant tout pour faire des requêtes !

Si on veut les utilisateurs avec leurs rôles on prend la relation classique :

$users = \App\User::with('roles')->get();

foreach ($users as $user) {
    echo '<strong>' . $user->name . '<br></strong>';
    foreach ($user->roles as $role) {
        echo '<li>' . $role->name . '</li>';
    }
}

On obtient bien les rôles des utilisateurs :

img40

Maintenant si on veut la même chose mais pour chaque rôle les tags correspondants alors on transite par le modèle du pivot :

$users = \App\User::with('role_users.role', 'role_users.tags')->get();

foreach ($users as $user) {
    echo '<strong>' . $user->name . '<br></strong>';
    foreach ($user->role_users as $role_user) {
        echo $role_user->role->name . ' :<br>';
        foreach ($role_user->tags as $tag) {
            echo '<li>' . $tag->name . '</li>';
        }
    }
    echo '<br>';
}

On obtient bien ce qu’on voulait :

img41

On peut évidemment faire le même genre de requête en partant des rôles.

  1. corsaire

    bonjour vos sujets sont très interessants . j aimerais vous poser une question . après avoir lu votre article je me rend compte que vous n utiliser pas la methode beginTransaction() . etant debutant en laravel j ai voulu faire un formulaire qui est rattaché a plusieurs table avec une table pivot . A quel moment utiliser la methode beginTransaction() pour etre sur que toutes les données ont été bien enregistrés . Merci

    • Author bestmomo

      Bonjour,

      Pour les transactions c’est bien expliqué dans la documentation. Il faut englober toutes les instructions qui comportent des requêtes dépendantes avec DB::transaction ou utiliser DB::beginTransaction et DB::commit.

      Cordialement

  2. Author bestmomo

    Personnellement je vois plutôt ainsi.

    Mais évidemment un champ ENUM dans le pivot etablissement_user simplifierait bien les choses.

    • lsteamgeo

      Merci pour votre aide.

      Dans la solution du champ ENUM, ce champ comporterait « en dure » le rôle (admin, responsable, …) c’est bien ça?
      Si l’utilisateur possède 2 rôles dans l’établisement X, il y aura donc deux enregistrements dans la table pivot etablissement_user ?
      Avec Eloquent, il est possible de gérer ça sans trop de soucis (attach, detach) ?

      • Author bestmomo

        Oui c’est ça. Eloquent sait gérer des colonnes supplémentaires dans les pivots. Il faut les définir dans la relation avec withPivot et ensuite la méthode pivot permet d’aller récupérer les valeurs.

        • lsteamgeo

          Oui, j’utilise déjà withPivot pour récupérer un identifiant unique qui se trouve dans la table pivot et qui correspond au couple utilisateur/role.

          Je vais donc essayer dans un premier temps cette approche qui est à priori plus facile à mettre en place.

          Merci encore !

    • Author bestmomo

      Bonsoir,

      Dans cette configuration ça correspond exactement à la structure évoquée dans l’article, donc a priori ça peut fonctionner. Mais je n’ai pas exploré toutes les possibilités.

      • lsteamgeo

        Je vais donc tester cette configuration. Merci et félicitation pour votre travail !

        • Author bestmomo

          Je me pose quand même une question sur ce modèle. Ca veut dire que les utilisateurs ont un rôle indépendamment des établissements et on greffe ces rôles selon les cas à certains établissements ? Ou alors c’est un tout et les rôles sont spécifiques aux établissements ?

          J’avais compris qu’on a des utilisateurs pour des établissements et pour chacun de ces établissements chaque utilisateur à 1 ou plusieurs rôles. Autrement dit j’aurais établi d’abord la relation entre utilisateurs et établissements et greffé le rôle au niveau du pivot.

          • lsteamgeo

            Les rôles sont définis au préalable et sont identiques pour chaque établissement (admin, responsable, visiteur, …).

            L’utilisateur se voit attribuer un ou plusieurs rôles selon l’établissement auquel il a accès. Il peut appartenir à 1 ou plusieurs établissements avec les mêmes ou différents rôles. Dans la majorité des cas, l’utilisateur appartiendra à 1 seul établissement avec un seul rôle.

            Le principe général est celui-ci :
            L’utilisateur se connecte via une identification simple (email/mot de passe) puis choisit (s’il en a plusieurs) l’établissement auquel il souhaite accéder. Une fois dans « l’espace » établissement sélectionné, il choisit avec quel rôle (s’il en a plusieurs) il souhaite accéder à l’établissement. Il peut bien entendu changer de rôle et/ou d’établissement sans se reconnecter.

            Qu’en pensez-vous ?

            Merci

  3. lsteamgeo

    Bonjour,

    Je me permets de vous contacter pour avoir votre avis concernant une gestion authentification/autorisation.

    Cela fait plusieurs jours que je cherche une solution fiable pour mon problème avec Laravel et Eloquent.

    La gestion de l’authentification et des autorisations simples est assez facile avec Laravel par un couple Users/Roles.

    En revanche, j’ai des difficultés à trouver une solution pour mon problème.

    Je m’explique :

    Un utilisateur peut appartenir à 1 ou plusieurs Etablissements.
    Un utilisateur peut posséder 1 ou plusieurs Roles dans un Etablissement défini.
    Un utilisateur possède un numéro unique lié à son Role.
    L’utilisateur se connecte avec un identifiant unique puis choisit dans quel Etablissement il souhaite accéder et avec quel Role (selon les droits définis par l’administrateur).

    Voila la problématique, je n’arrive pas à trouver une solution « simple » avec Eloquent pour gérer les différentes relations. Dans mes solutions, je me retrouve toujours à gérer plusieurs clés étrangères dans une même table. Or, Eloquent n’est pas très adapté pour cette pratique.

    Cela fait un petit moment que je suis votre travail et je pense que vous pourrez m’aider pour trouver une solution.

    Merci beaucoup

    • Author bestmomo

      Bonjour,

      D’après la situation on a une relation n:n entre users et établissements, donc un pivot entre les deux. Jusque là c’est du classique. Le souci vient pour définir les rôles. L’approche la plus simple serait un champ ENUM dans le pivot si c’est possible. Parce qu’on peut facilement gérer un champ supplémentaire d’un pivot.

      Si pour une raison particulière ce n’est pas possible alors il est possible d’adopter la même approche que celle de cet article mais au lieu d’ajouter du n:n on ajoute du 1:n.

Laisser un commentaire