Many to many to many…
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 :
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 :
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 :
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 :
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 :
On peut évidemment faire le même genre de requête en partant des rôles.
18 commentaires
pok_01
Bonjour,
Je déterre un vieil article… Je suis confronté actuellement à ce type de relation avec « double » many to many.
Pour la lecture de données via la seconde table pivot, pas de problème.
Mais concernant l’écriture, comment puis-je faire?
Dans User.php, j’appelle pour le moment via mon controlleur:
public function syncRoles(array $rolesData)
{
$this->Roles()->sync($rolesData);
}
La table pivot role_user se remplit, ok. Comment faire pour remplir la seconde role_user_tag?
J’ai tenté d’ajouter dans la même function (sans réussite):
$this->Role_user()->tags()
Merci par avance!
bestmomo
Salut,
C’est vrai que je n’avais traité dans cet article que les recherches d’enregistrements et pas l’ajout. Pour les suppressions ça peut se régler avec des cascades. C’est pour les ajouts que ça se complique… Tu as besoin de l’id de l’enregistrement dans le premier pivot pour dans un second temps remplir le second pivot. Ça semble laborieux, mais à part utiliser des attach, récupérer le dernier id du pivot pour ensuite remplir le second, je ne vois pas trop.
pok_01
Super! Merci beaucoup!
J’ai réussi à trouver mon bonheur en complémentant votre réponse avec un autre forum!
kaddour.fellah
Bonjour,
je suis en Laravel 5.6 expérience débutant avancé. ma question est plutôt d’ordre de nommage au niveau des noms des fichiers de migration.
Normalement quand on crée une migration pour création de table cela donne ceci : php artisan make:migration create_NOM de la _table.
Pour des contraintes exemple ci-dessus (class CreateForeignKeys) le nommage de la migration, vous me conseillez quoi ? php artisan make:migration ???????????
Merci
kaddour
bestmomo
Salut,
Pour les contraintes j’ai plutôt tendance à les placer directement dans les migrations des tables. Sinon le nom importe peu, le seul effet est de nommer la classe générée. Donc par exemple create_foreign_keys va donner la classe CreateForeignKeys.
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
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
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) ?
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 !
lsteamgeo
Bonsoir,
Merci pour la réponse.
Est-ce que ce concept pourrait fonctionner (image workbench) ?
https://s21.postimg.org/fkxac8u2f/bdd_user.jpg
Si vous pensez que oui, je me lancerai dans cette direction ce week-end.
Merci beaucoup
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 !
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
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
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.