Maîtrisez la relation morphMany

Publié le 16 janvier 2024 par Mathieu De Gracia
Couverture de l'article Maîtrisez la relation morphMany

Parmi toutes les relations proposées par Laravel, les relations polymorphiques sont probablement les plus complexes et exotiques à mettre en place.

Dans ce tutoriel, démystifions les relations morphMany et morphOne à l'aide d'un exercice pratique !

  1. Prérequis
  2. Pourquoi utiliser un polymorphe
  3. Les bases
  4. Trier les relations
  5. Quelques relations plus complexes
  6. Depuis les factory
  7. Customiser la relation

Prérequis

Avant de poursuivre ce tutoriel nous allons créer les migrations, les modèles et les factories nécessaires au bon fonctionnement de notre relation polymorphique à l'aide des commandes suivantes :

1php artisan make:model Post --migration --factory
2php artisan make:model Comment --migration --factory
3php artisan make:model Image --migration --factory

Deux colonnes supplémentaires seront également nécessaires à notre table images, nous verrons leurs importances un peu plus tard :

1Schema::create('images', function (Blueprint $table) {
2 $table->id();
3 $table->integer('imageable_id');
4 $table->string('imageable_type');
5 $table->timestamps();
6});

Nous allons désormais ajouter dans nos modèles Post et Comment une relation morphMany dénommée images :

1class Post extends Model
2{
3 use HasFactory;
4 
5 public function images()
6 {
7 return $this->morphMany(Image::class, 'imageable');
8 }
9}
10 
11class Comment extends Model
12{
13 use HasFactory;
14 
15 public function images()
16 {
17 return $this->morphMany(Image::class, 'imageable');
18 }
19}

Pour finir, ajoutons la relation imageable à notre modèle Image, cette dernière contiendra le morphTo faisant lien avec nos deux précédents modèles :

1namespace App\Models;
2 
3use Illuminate\Database\Eloquent\Factories\HasFactory;
4use Illuminate\Database\Eloquent\Model;
5 
6class Image extends Model
7{
8 use HasFactory;
9 
10 public function imageable()
11 {
12 return $this->morphTo();
13 }
14}

Votre application est désormais prête à utiliser une relation polymorphique !

Pourquoi utiliser un polymorphe

Dans le cadre de ce tutoriel, nous possédons un modèle Image pouvant être lié à l'aide d'un polymorphe soit à un model Post ou alors à un model Comment :

Dans cette situation, les modèles Post et Comment possèdent des images, cette relation est polymorphique car la ligne insérée dans la table images conserve un lien vers le model associé :

Une relation polymorphique offre une certaine souplesse dans votre structure de données, il sera possible de créer un nouveau modèle possédant une image sans avoir à retoucher à la structure de votre base de données, le modèle Image pourra facilement accueillir un nouveau modèle grâce à son polymorphisme.

Le second intérêt d'une relation polymorphique réside dans la possibilité de lier plusieurs images à un même modèle sans avoir à créer de table intermédiaire, comme par exemple dans le cadre d'une relation belongsToMany.

Pour résumer, grace à la relation polymorphique :

Un polymorphe permet de lier un modèle à n'importe quel autre modèle

Les bases du morphMany

Maintenant que notre relation polymorphique est fonctionnelle et que nous en savons un peu plus sur ce type de relation, commençons à manipuler notre morphMany :

1$post = Post::create();
2 
3$post->images()->save(Image::make());
4$post->images()->save(Image::make());
5$post->images()->save(Image::make());

Une fois associée à un post, les images seront par la suite facilement accessibles depuis la relation images du modèle :

1$post = Post::first();
2 
3$image = $post->images()->first();

Dans le sens inverse, vous pourrez récupérer le modèle associé à une image depuis la relation imageable, contenant le morphTo, que nous avons créé précédemment :

1$image = Image::first();
2 
3$post = $image->imageable;

Trier les relations

À l'instar d'autres relations du framework, il est envisageable de trier une relation polymorphique à l'aide des méthodes oldestOfMany et latestOfMany.

Pour ce faire, ajoutons les deux méthodes suivantes dans notre modèle Post :

1class Post extends Model
2{
3 use HasFactory;
4 
5 public function images()
6 {
7 return $this->morphMany(Image::class, 'imageable');
8 }
9 
10 public function oldestImage()
11 {
12 return $this->morphOne(Image::class, 'imageable')->oldestOfMany();
13 }
14 
15 public function latestImage()
16 {
17 return $this->morphOne(Image::class, 'imageable')->latestOfMany();
18 }
19}

Petite particularité, l'utilisation de ces méthodes de tri nécessite l'ajout d'un morphOne à la place du morphMany, vous pourrez dès lors facilement récupérer les bonnes images :

1$post = Post::first();
2 
3$oldestImage = $post->oldestImage;
4$latestImage = $post->latestImage;

Attention, par défaut ce tri s'effectue sur la colonne id de votre modèle et non, comme on pourrait l'imaginer, sur la valeur de la colonne created_at. Vous trouverez le code associé à ces deux méthodes dans la classe CanBeOneOfMany du framework :

1public function oldestOfMany($column = 'id', $relation = null)
2{
3 return $this->ofMany(collect(Arr::wrap($column))->mapWithKeys(function ($column) {
4 return [$column => 'MIN'];
5 })->all(), 'MIN', $relation);
6}

Ce comportement pourrait devenir problématique si la colonne id de votre modèle n'est pas auto incrémentée par votre engine de base de données, vous pourrez modifier ce comportement en précisant la colonne à utiliser pour effectuer le tri directement depuis la méthode :

1->oldestOfMany('created_at');
2->latestOfMany('created_at');

Voyons désormais comment effectuer des requêtes plus complexes à partir d'une relation polymorphique.

Quelques relations plus complexes

Pour commencer, modifions la migration de notre table images en y ajoutant une colonne likes de type integer :

1Schema::create('images', function (Blueprint $table) {
2 $table->id();
3 $table->integer('imageable_id');
4 $table->string('imageable_type');
5 $table->integer('likes')->default(0);
6 $table->timestamps();
7});

N'oubliez pas de modifier la factory et le modèle en conséquence !

Imaginons que nous souhaitions récupérer l'image ayant le plus de "likes" d'un post, pour ce faire nous pouvons utiliser un morphOne couplé à un ofMany afin de réaliser la requête dans une nouvelle méthode mostLikedImage :

1class Post extends Model
2{
3 use HasFactory;
4 
5 public function images()
6 {
7 return $this->morphMany(Image::class, 'imageable');
8 }
9 
10 public function mostLikedImage()
11 {
12 return $this->morphOne(Image::class, 'imageable')->ofMany([
13 'likes' => 'max',
14 ]);
15 }
16}

La méthode ofMany accepte en premier argument un tableau contenant le nom de la colonne sur laquelle effectuer le tri ainsi qu'une fonction d'agrégation, à ce jour, seules les fonctions min et max sont utilisables.

Comme bien souvent dans Laravel, le framework cherchera à être explicite et relativement verbeux dans ses conventions de nommage : "morph one of many".

1$post = Post::first();
2 
3$image = $post->mostLikedImage;

Le second argument de la méthode ofMany accepte quant à lui une closure recevant une instance de $query, vous pourrez ainsi modifier en profondeur la requête exécutée par la relation.

Par exemple, si nous souhaitons récupérer l'image ayant le plus de likes sur la dernière semaine, nous pouvons modifier la requête de la manière suivante :

1class Post extends Model
2{
3 use HasFactory;
4 
5 public function images()
6 {
7 return $this->morphMany(Image::class, 'imageable');
8 }
9 
10 public function mostLikedImage()
11 {
12 return $this->morphOne(Image::class, 'imageable')->ofMany([
13 'likes' => 'max',
14 ], function ($query) {
15 return $query->where('created_at', '>', now()->subWeek());
16 });
17 }
18}

Depuis les factory

Une relation polymorphique peut, au premier abord, sembler complexe à créer depuis une factory, un simple each vous permettra de créer facilement les relations vers notre modèle Image :

1Post::factory()->create()->each(function (Post $post) {
2 
3 $images = Image::factory()->count(3)->make();
4 
5 $post->images()->saveMany($images);
6});
7 
8Comment::factory()->create()->each(function (Comment $comment) {
9 
10 $images = Image::factory()->count(3)->make();
11 
12 $comment->images()->saveMany($images);
13});

Customiser la relation

Dans le cadre d'une relation polymorphique, Laravel conservera le Fully Qualified Class Name (FQCN) de la classe dans le champ imageable_type que nous avons créé précédemment dans ce tutoriel.

En d'autres termes, cela signifie que le namespace complet de vos classes apparaîtra dans votre base de données et provoquera un couplage malencontreux entre votre code et votre BDD.

Ce comportement par défaut peut paraitre anodin mais constitue un élément structurant de votre application que l'on pourrait négliger lors de la mise en place de nos relations.

Une base de données est un composant de votre application évoluant à un rythme différent de votre code, votre base de données ne devrait pas avoir connaissance de la structure de votre code, cela n'est pas de sa responsabilité.

En laissant ce comportement par défaut, vous vous retrouverez un jour ou l'autre dans l'embarras quand vous déciderez de changer le nom ou le namespace d'un modèle, il devient alors essentiel de garder le contrôle sur vos relations.

Heureusement pour nous, Laravel nous autorise à configurer ce comportement en nommant nous-même la valeur à sauvegarder pour chaque modèle dans une relation polymorphique :

1Relation::enforceMorphMap([
2 'post' => Post::class,
3 'comment' => Comment::class,
4]);

Après cette configuration, la valeur sauvegardée en base de données ne sera plus App\Models\Post mais uniquement post, la base de données n'aura plus conscience de la structure de votre code et votre application pourra évoluer plus sereinement !

Mathieu De Gracia avatar
Mathieu De Gracia
Des fois, mon chat code à ma place 🐱

A lire

Autres articles de la même catégorie