Modèles : plusieurs à plusieurs
Bloc Laravel · séance 5 · ~50 min
Au chapitre Views, la fiche d'un concert affichait ses invités. Cette liste a disparu au chapitre 10, quand les concerts sont passés en base. Dans ce chapitre, les invités reviennent avec leur propre table, artists, et une troisième table qui relie les artistes aux concerts. Vous comptez ensuite les requêtes qu'une boucle envoie à la base, puis vous chargez les relations avec with() pour éviter une requête de plus par concert.
À la fin de ce chapitre, vous serez capables de :
- expliquer pourquoi une relation plusieurs à plusieurs demande une table pivot, et écrire sa migration,
- déclarer une relation
belongsToManydes deux côtés, et la dessiner dans un diagramme de classes, - relier et délier des lignes avec
attach,detachetsync, - compter les requêtes d'une boucle, et les réduire avec
with(), - prévoir ce que la base supprime quand une ligne liée disparaît.
1. Deux listes qui se croisent
Voici les invités écrits dans le tableau $concerts du chapitre Views :
1 => [
'artist' => 'Duo Mistral',
…
'guests' => [],
],
2 => [
'artist' => "L'Orchestre du Lac",
…
'guests' => ['Ana Lopes', 'Karim Benali'],
],
3 => [
'artist' => 'Orage Mécanique',
…
'guests' => ['Les Tambours du Parc'],
],
Le concert 1, celui de Duo Mistral, n'avait pas d'invité. Ana Lopes y est maintenant invitée aussi : elle joue à deux concerts, et L'Orchestre du Lac reçoit deux invités.
Première idée : une colonne de texte guests dans la table concerts, qui contient les noms séparés par des virgules.
concerts
id | artist | guests
1 | Duo Mistral |
2 | L'Orchestre du Lac | Ana Lopes, Karim Benali
3 | Orage Mécanique | Les Tambours du Parc
Seconde idée : une table artists, avec une clé étrangère concert_id, comme la colonne stage_id du chapitre 11.
artists
id | name | concert_id
1 | Ana Lopes | 2
2 | Karim Benali | 2
3 | Les Tambours du Parc | 3
Le code qui sent mauvais
En binôme, cinq minutes. Dans chacune des deux tables, comment enregistrez-vous qu'Ana Lopes joue aussi au concert de Duo Mistral ?
Au chapitre 11, entre une scène et ses concerts, la multiplicité vaut 1 d'un côté et 0..* de l'autre : la clé étrangère se range du côté 0..*, dans concerts. Entre un concert et ses invités, elle vaut 0..* des deux côtés : c'est la relation plusieurs à plusieurs du chapitre 4 de la POO, comme entre Hero et Quest. Une colonne artist_id dans concerts ne garderait qu'un invité par concert, et une colonne concert_id dans artists qu'un concert par artiste.
La solution est une troisième table, qui ne contient que des paires de numéros :
artists artist_concert concerts
id | name artist_id | concert_id id | artist
1 | Ana Lopes 1 | 1 1 | Duo Mistral
2 | Karim Benali 1 | 2 2 | L'Orchestre du Lac
3 | Les Tambours du Parc 2 | 2 3 | Orage Mécanique
3 | 3
Chaque ligne de la table du milieu se lit « cet artiste est invité à ce concert ». Le numéro 1 d'Ana Lopes y apparaît deux fois, mais son nom n'est écrit qu'une fois, dans artists. Cette table est la table pivot annoncée au chapitre 4 de la POO : elle relie deux tables par des paires de clés étrangères.

2. La table des artistes et la table pivot
Créez d'abord la table des artistes :
php artisan make:migration create_artists_table
INFO Migration [database/migrations/2026_09_28_222952_create_artists_table.php] created successfully.
Schema::create('artists', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('instrument');
$table->timestamps();
});
instrument contient l'instrument principal de l'artiste. Créez ensuite la table pivot :
php artisan make:migration create_artist_concert_table
INFO Migration [database/migrations/2026_09_28_222954_create_artist_concert_table.php] created successfully.
Dans le fichier créé, remplacez les lignes id() et timestamps() :
Schema::create('artist_concert', function (Blueprint $table) {
$table->foreignId('artist_id')->constrained()->cascadeOnDelete();
$table->foreignId('concert_id')->constrained()->cascadeOnDelete();
$table->primary(['artist_id', 'concert_id']);
});
- Les deux
foreignId()fonctionnent commestage_idau chapitre 11 : la base refuse un numéro qui ne désigne aucune ligne, et supprime la paire quand l'artiste ou le concert disparaît. primary(['artist_id', 'concert_id'])fait de la paire la clé primaire de la table. Une clé primaire faite de plusieurs colonnes s'appelle une clé primaire composée. La base refuse deux lignes avec la même paire : Ana Lopes ne peut pas être invitée deux fois au même concert.- Pas d'
id(), car une ligne se reconnaît à sa paire. Pas detimestamps(), car aucune page n'affiche la date d'une invitation.
Le nom artist_concert suit la convention de Laravel pour une table pivot : les noms des deux modèles, au singulier et en minuscules, dans l'ordre alphabétique, séparés par _. Le a d'artist vient avant le c de concert : la table s'appelle donc artist_concert, et non concert_artist.
php artisan migrate
php artisan migrate:status
INFO Running migrations.
2026_09_28_222952_create_artists_table ......................... 4.91ms DONE
2026_09_28_222954_create_artist_concert_table .................. 4.23ms DONE
Migration name .............................................. Batch / Status
…
2026_09_28_220038_add_stage_id_to_concerts_table ................... [1] Ran
2026_09_28_222952_create_artists_table ............................. [2] Ran
2026_09_28_222954_create_artist_concert_table ...................... [2] Ran
php artisan db:table artist_concert
main.artist_concert ........................................................
Columns .................................................................. 2
Column ................................................................ Type
artist_id integer .................................................. integer
concert_id integer ................................................. integer
Index ......................................................................
sqlite_autoindex_artist_concert_1 artist_id, concert_id .. compound, primary
Foreign Key .......................................... On Update / On Delete
concert_id references id on concerts ................... no action / cascade
artist_id references id on artists ..................... no action / cascade
La ligne Index montre la clé primaire, primary, faite des deux colonnes, compound. Les deux lignes Foreign Key montrent les suppressions en cascade.
3. Les deux modèles et belongsToMany
php artisan make:model Artist
INFO Model [app/Models/Artist.php] created successfully.
Donnez à Artist sa liste #[Fillable] et une relation vers les concerts :
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
#[Fillable(['name', 'instrument'])]
class Artist extends Model
{
public function concerts(): BelongsToMany
{
return $this->belongsToMany(Concert::class);
}
}
Dans Concert, importez BelongsToMany sous BelongsTo, et ajoutez la méthode guests() sous stage() :
public function guests(): BelongsToMany
{
return $this->belongsToMany(Artist::class);
}
belongsToMany(Concert::class) se lit « un artiste est lié à plusieurs concerts ». Pour trouver ces concerts, Eloquent a besoin de trois noms, qu'il déduit des deux classes :
- la table pivot,
artist_concert, faite des nomsArtistetConcertrangés dans l'ordre alphabétique, - la colonne de la classe qui déclare la relation,
artist_id, le nomArtisten minuscules suivi de_id, - la colonne de la classe visée,
concert_id, de la même façon.
Le nom de la méthode ne sert à aucune de ces déductions. Dans Concert, elle s'appelle guests(), et non artists(), parce qu'elle renvoie les invités du concert. Au chapitre 11, belongsTo() lisait au contraire le nom de la méthode stage() pour trouver stage_id.
Ce que la relation demande à la base
Recréez la route bac à sable à la fin de routes/web.php, et importez Artist et Concert au-dessus de use Illuminate\Support\Facades\Route; :
use App\Models\Artist;
use App\Models\Concert;
Route::get('/bac-a-sable', function () {
$concert = Concert::find(2);
dump($concert->guests);
dd($concert->guests()->toRawSql());
});
Illuminate\Database\Eloquent\Collection {#1102 ▼ // routes/web.php:188
#items: []
#escapeWhenCastingToString: false
}
"select * from "artists" inner join "artist_concert" on "artists"."id" = "artist_concert"."artist_id" where "artist_concert"."concert_id" = 2" // routes/web.php:189
La collection est vide, car la table pivot ne contient encore aucune paire. La requête se lit en trois morceaux :
where "artist_concert"."concert_id" = 2garde les paires du concert 2,inner join "artist_concert" on "artists"."id" = "artist_concert"."artist_id"associe à chaque paire la ligne d'artistsdont l'idvautartist_id,select * from "artists"renvoie ces lignes d'artistes.
Une requête qui assemble ainsi les lignes de deux tables à partir d'une valeur commune s'appelle une jointure, join en SQL. En une seule requête, elle renvoie les artistes dont l'id figure dans les paires du concert 2.
Le diagramme de classes
@startuml
class Stage {
+name: string
+capacity: int
+location: string
+concerts: Concert [0..*]
}
class Concert {
+artist: string
+starts_at: Carbon
+duration: int
+description: string
+stage: Stage [1]
+guests: Artist [0..*]
}
class Artist {
+name: string
+instrument: string
+concerts: Concert [0..*]
}
Stage "1 +stage" *-- "0..* +concerts" Concert : accueille >
Concert "0..* +concerts" -- "0..* +guests" Artist : invite >
@enduml
Le trait entre Concert et Artist porte 0..* à chaque bout : un concert invite zéro, un ou plusieurs artistes, et un artiste est invité à zéro, un ou plusieurs concerts. Chaque rôle est le nom que le code utilise, guests dans Concert et concerts dans Artist, et revient dans la boîte de la classe qui le porte.
Le losange plein {uml:composition} entre Stage et Concert est celui du chapitre 11 : un concert disparaît avec sa scène. Entre Concert et Artist, le trait reste une association simple {uml:association}, car un artiste existe sans le concert. La table artist_concert n'apparaît pas : c'est une table de la base, pas une classe, et le trait la représente.
Dans Concert, artist reste un texte : la tête d'affiche garde sa colonne dans concerts, que toutes les pages lisent déjà, et seuls les invités passent dans artists.
4. Relier : attach, detach et sync
Enregistrez Ana Lopes et Karim Benali, puis invitez-les :
Route::get('/bac-a-sable', function () {
$ana = Artist::create(['name' => 'Ana Lopes', 'instrument' => 'chant']);
$karim = Artist::create(['name' => 'Karim Benali', 'instrument' => 'oud']);
Concert::find(1)->guests()->attach($ana->id);
Concert::find(2)->guests()->attach([$ana->id, $karim->id]);
return 'Invités enregistrés';
});
Invités enregistrés
La méthode guests(), avec parenthèses, renvoie la relation, comme stage() au chapitre 11. Sa méthode attach() insère une paire dans artist_concert pour chaque numéro d'artiste reçu, seul ou dans un tableau.
Chaque visite de cette page crée deux artistes de plus : ouvrez-la une seule fois. Dans SQLite Viewer, la table artist_concert contient alors trois lignes :
artist_id | concert_id
1 | 1
1 | 2
2 | 2
Lisez maintenant la relation dans les deux sens :
Route::get('/bac-a-sable', function () {
foreach (Concert::find(2)->guests as $guest) {
dump($guest->name);
}
$ana = Artist::where('name', 'Ana Lopes')->first();
foreach ($ana->concerts as $concert) {
dump($concert->artist);
}
});
"Ana Lopes" // routes/web.php:187
"Karim Benali" // routes/web.php:187
"Duo Mistral" // routes/web.php:193
"L'Orchestre du Lac" // routes/web.php:193
$concert->guests cherche dans artist_concert les paires du concert, et $ana->concerts celles de l'artiste.
La même paire deux fois
Invitez une seconde fois Ana Lopes au concert 2 :
Route::get('/bac-a-sable', function () {
$ana = Artist::where('name', 'Ana Lopes')->first();
Concert::find(2)->guests()->attach($ana->id);
return 'Ana Lopes invitée';
});
Illuminate\Database\UniqueConstraintViolationException
SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: artist_concert.artist_id, artist_concert.concert_id (Connection: sqlite, …, SQL: insert into "artist_concert" ("artist_id", "concert_id") values (1, 2))
attach() envoie directement l'insertion de la paire (1, 2), sans requête pour vérifier qu'elle existe déjà. La base la refuse : UNIQUE constraint failed cite les deux colonnes de la clé primaire composée. Sans cette clé, la fiche du concert afficherait Ana Lopes deux fois.
Délier
detach() retire des paires du concert sur lequel vous l'appelez. detach($karim->id) retire la paire de Karim Benali, et detach() sans argument retire toutes les paires du concert.
Route::get('/bac-a-sable', function () {
$concert = Concert::find(2);
$karim = Artist::where('name', 'Karim Benali')->first();
$concert->guests()->detach($karim->id);
dump($concert->guests()->count());
$concert->guests()->detach();
dd($concert->guests()->count());
});
1 // routes/web.php:190
0 // routes/web.php:193
Le concert 2 n'a plus d'invité, et Karim Benali existe toujours dans artists. La table pivot ne contient plus que la paire (1, 1), qui relie Ana Lopes au concert de Duo Mistral.
Remplacer toute la liste
sync() reçoit la liste complète des invités voulus pour le concert. Il compare cette liste avec les paires du concert, ajoute celles qui manquent et retire celles qui n'y figurent pas.
Route::get('/bac-a-sable', function () {
$concert = Concert::find(1);
$karim = Artist::where('name', 'Karim Benali')->first();
foreach ($concert->guests()->get() as $guest) {
dump('avant : ' . $guest->name);
}
$concert->guests()->sync([$karim->id]);
foreach ($concert->guests()->get() as $guest) {
dump('après : ' . $guest->name);
}
});
"avant : Ana Lopes" // routes/web.php:190
"après : Karim Benali" // routes/web.php:196
Les boucles lisent guests()->get(), qui envoie une nouvelle requête à chaque appel. La propriété guests resterait en mémoire après la première boucle, comme au chapitre 11, et la seconde afficherait encore Ana Lopes. sync() renvoie aussi un tableau qui détaille ses changements :
Route::get('/bac-a-sable', function () {
$concert = Concert::find(1);
$ana = Artist::where('name', 'Ana Lopes')->first();
$karim = Artist::where('name', 'Karim Benali')->first();
dd($concert->guests()->sync([$ana->id, $karim->id]));
});
array:3 [▼ // routes/web.php:190
"attached" => array:1 [▼
0 => 1
]
"detached" => []
"updated" => []
]
Karim Benali était déjà invité : sync() n'a ajouté qu'Ana Lopes, l'artiste 1, sans erreur. La clé updated concerne des colonnes supplémentaires d'une table pivot, que artist_concert n'a pas. Rechargez la page : les tableaux attached, detached et updated sont vides, car les deux paires existent déjà.
Écrivez attach() pour ajouter un invité, et sync() quand vous connaissez la liste complète des invités.
Le seeder et la fabrique
Créez une fabrique et un seeder pour les artistes :
php artisan make:factory ArtistFactory --model=Artist
php artisan make:seeder ArtistSeeder
INFO Factory [database/factories/ArtistFactory.php] created successfully.
INFO Seeder [database/seeders/ArtistSeeder.php] created successfully.
Comme au chapitre 11, make:model n'a pas ajouté le trait HasFactory : collez-le dans Artist, avec son import, comme dans Stage. Remplissez ensuite la fabrique :
public function definition(): array
{
return [
'name' => fake()->name(),
'instrument' => fake()->randomElement(['guitare', 'violon', 'piano', 'batterie', 'saxophone']),
];
}
Dans ArtistSeeder, importez Artist et Concert. Écrivez les trois invités du chapitre Views, ajoutez Ana Lopes au concert 1, puis créez dix artistes de fabrique :
public function run(): void
{
$ana = Artist::create(['name' => 'Ana Lopes', 'instrument' => 'chant']);
$karim = Artist::create(['name' => 'Karim Benali', 'instrument' => 'oud']);
$drums = Artist::create(['name' => 'Les Tambours du Parc', 'instrument' => 'percussions']);
Concert::where('artist', 'Duo Mistral')->first()->guests()->attach($ana->id);
Concert::where('artist', "L'Orchestre du Lac")->first()->guests()->attach([$ana->id, $karim->id]);
Concert::where('artist', 'Orage Mécanique')->first()->guests()->attach($drums->id);
$artists = Artist::factory()->count(10)->create();
foreach (Concert::where('id', '>', 3)->get() as $concert) {
$concert->guests()->attach($artists->random(2));
}
}
where('id', '>', 3)place un opérateur de comparaison entre la colonne et la valeur. La boucle parcourt donc les concerts 4 à 23, ceux de la fabrique.$artists->random(2)prend au hasard deux artistes différents dans la collection. Une même paire n'est donc jamais insérée deux fois.attach()accepte une collection d'objets, dont il lit lesid.
Dans DatabaseSeeder, appelez ArtistSeeder après ConcertSeeder, car il cherche des concerts qui doivent déjà exister :
$this->call(StageSeeder::class);
$this->call(ConcertSeeder::class);
$this->call(ArtistSeeder::class);
php artisan migrate:fresh --seed
…
INFO Seeding database.
Database\Seeders\StageSeeder ....................................... RUNNING
Database\Seeders\StageSeeder ..................................... 8 ms DONE
Database\Seeders\ConcertSeeder ..................................... RUNNING
Database\Seeders\ConcertSeeder .................................. 90 ms DONE
Database\Seeders\ArtistSeeder ...................................... RUNNING
Database\Seeders\ArtistSeeder .................................. 134 ms DONE
La base contient treize artistes et quarante-quatre paires : les quatre paires écrites à la main, puis deux invités pour chacun des vingt concerts de fabrique. Les noms inventés par Faker changent à chaque lancement.
5. La fiche du concert retrouve ses invités
Dans concerts/show.blade.php, ajoutez sous les deux descriptions la liste du chapitre Views, qui lit maintenant la relation :
<h2>Invités</h2>
<ul>
@forelse ($concert->guests as $guest)
<li>{{ $guest->name }}, {{ $guest->instrument }}</li>
@empty
<li>Aucun invité annoncé</li>
@endforelse
</ul>
curl -s http://premier-test-laravel.test/concerts/1
curl -s http://premier-test-laravel.test/concerts/2
…
<h2>Invités</h2>
<ul>
<li>Ana Lopes, chant</li>
</ul>
…
<h2>Invités</h2>
<ul>
<li>Ana Lopes, chant</li>
<li>Karim Benali, oud</li>
</ul>
…
Pour voir le bloc @empty, retirez les invités du concert 3 dans la route bac à sable, puis envoyez le navigateur vers sa fiche :
Route::get('/bac-a-sable', function () {
Concert::find(3)->guests()->detach();
return redirect()->route('concerts.show', 3);
});
<h2>Invités</h2>
<ul>
<li>Aucun invité annoncé</li>
</ul>
Une page par artiste
php artisan make:controller ArtistController
php artisan make:view artists.show
INFO Controller [app/Http/Controllers/ArtistController.php] created successfully.
INFO View [resources/views/artists/show.blade.php] created successfully.
Le contrôleur suit le modèle de StageController :
<?php
namespace App\Http\Controllers;
use App\Models\Artist;
use Illuminate\View\View;
class ArtistController extends Controller
{
public function show(int $id): View
{
$artist = Artist::findOrFail($id);
$concerts = $artist->concerts()->orderBy('starts_at')->get();
return view('artists.show', ['artist' => $artist, 'concerts' => $concerts]);
}
}
<x-layouts.app :title="$artist->name">
<h1>{{ $artist->name }}</h1>
<p>{{ $artist->instrument }}</p>
<h2>Concerts</h2>
<ul>
@foreach ($concerts as $concert)
<li>
{{ $concert->starts_at->format('G \h') }}
<a href="{{ route('concerts.show', $concert->id) }}">{{ $concert->artist }}</a>
</li>
@endforeach
</ul>
</x-layouts.app>
Dans routes/web.php, importez ArtistController sous StageController, puis ajoutez la route au-dessus de la route bac à sable :
Route::get('/artistes/{id}', [ArtistController::class, 'show'])->whereNumber('id')->name('artists.show');
curl -s http://premier-test-laravel.test/artistes/1
…
<title>Ana Lopes · Les Nuits du Parc</title>
…
<h1>Ana Lopes</h1>
<p>chant</p>
<h2>Concerts</h2>
<ul>
<li>
18 h
<a href="http://premier-test-laravel.test/concerts/1">Duo Mistral</a>
</li>
<li>
20 h
<a href="http://premier-test-laravel.test/concerts/2">L'Orchestre du Lac</a>
</li>
</ul>
…
/artistes/99 répond 404. Sur la fiche du concert, faites de chaque nom d'invité un lien vers sa page :
<li><a href="{{ route('artists.show', $guest->id) }}">{{ $guest->name }}</a>, {{ $guest->instrument }}</li>
<h2>Invités</h2>
<ul>
<li><a href="http://premier-test-laravel.test/artistes/1">Ana Lopes</a>, chant</li>
<li><a href="http://premier-test-laravel.test/artistes/2">Karim Benali</a>, oud</li>
</ul>
Avant de mesurer, remettez la base à neuf. La commande rend son invité au concert 3, et sa sortie est celle de la section 4 :
php artisan migrate:fresh --seed
6. Compter les requêtes : le piège N+1
DB::enableQueryLog() demande à Laravel de garder en mémoire chaque requête envoyée ensuite à la base. DB::getQueryLog() renvoie le tableau de ces requêtes. Importez la classe DB au-dessus de Route :
use Illuminate\Support\Facades\DB;
Comptez les requêtes d'une boucle qui lit la scène de chaque concert, comme le fait le composant concert-card :
Route::get('/bac-a-sable', function () {
DB::enableQueryLog();
foreach (Concert::all() as $concert) {
$stageName = $concert->stage->name;
}
dd(count(DB::getQueryLog()));
});
24 // routes/web.php:196
Remplacez count(DB::getQueryLog()) par DB::getQueryLog(), puis dépliez les trois premières entrées (sortie raccourcie) :
array:24 [▼ // routes/web.php:196
0 => array:4 [▼
"query" => "select * from "concerts""
"bindings" => []
…
1 => array:4 [▼
"query" => "select * from "stages" where "stages"."id" = ? limit 1"
"bindings" => array:1 [▼
0 => 1
]
…
2 => array:4 [▼
"query" => "select * from "stages" where "stages"."id" = ? limit 1"
"bindings" => array:1 [▼
0 => 2
]
…
La première requête lit les vingt-trois concerts. Ensuite, chaque lecture de $concert->stage envoie sa propre requête, et le ? y reçoit la valeur de bindings, le stage_id du concert. Les scènes 1 et 2 sont donc demandées vingt-trois fois en tout.
Une requête pour la liste, puis une requête de plus par élément de la liste : c'est le piège N+1. Avec cinq cents concerts, la boucle enverrait cinq cent une requêtes. La page /concerts fait la même boucle dans le composant concert-card, à chaque visite.
Charger la relation avec with()
Route::get('/bac-a-sable', function () {
DB::enableQueryLog();
foreach (Concert::with('stage')->get() as $concert) {
$stageName = $concert->stage->name;
}
dd(DB::getQueryLog());
});
array:2 [▼ // routes/web.php:196
0 => array:4 [▼
"query" => "select * from "concerts""
"bindings" => []
…
1 => array:4 [▼
"query" => "select * from "stages" where "stages"."id" in (1, 2)"
"bindings" => []
…
with('stage') demande à Eloquent de charger la relation en même temps que la liste. Eloquent envoie la première requête, puis relève les stage_id des vingt-trois concerts, 1 et 2. Il envoie ensuite une seule requête avec in (1, 2), qui lit les deux scènes.
Enfin, Eloquent range chaque scène dans la propriété stage des concerts qui la désignent. Dans la boucle, $concert->stage lit cette propriété gardée en mémoire, sans requête.

with() accepte aussi un tableau de relations :
Route::get('/bac-a-sable', function () {
DB::enableQueryLog();
foreach (Concert::with(['stage', 'guests'])->get() as $concert) {
$stageName = $concert->stage->name;
foreach ($concert->guests as $guest) {
$guestName = $guest->name;
}
}
dump(count(DB::getQueryLog()));
dd(DB::getQueryLog()[2]['query']);
});
3 // routes/web.php:200
"select "artists".*, "artist_concert"."concert_id" as "pivot_concert_id", "artist_concert"."artist_id" as "pivot_artist_id" from "artists" inner join "artist_concert" on "artists"."id" = "artist_concert"."artist_id" where "artist_concert"."concert_id" in (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23)" // routes/web.php:201
Trois requêtes, là où la même boucle sans with() en envoie quarante-sept : une pour les concerts, vingt-trois pour les scènes et vingt-trois pour les invités. La troisième requête est la jointure de la section 3, avec in (1, …, 23) à la place de = 2. Les deux colonnes pivot_… indiquent à Eloquent à quel concert revient chaque artiste.
Appliquez la règle dans ConcertController. La méthode index() charge les scènes avec les concerts :
$concerts = Concert::with('stage')->orderBy('starts_at')->get();
La page /concerts affiche le même HTML. Sa boucle, dans concert-card, n'envoie plus que les deux requêtes de la route bac à sable, au lieu de vingt-quatre. La méthode show() ne change pas : la fiche /concerts/2 envoie trois requêtes, pour le concert, sa scène et ses invités, avec ou sans with().
Quand une boucle affiche une relation, chargez cette relation avec with().
7. Modifier et supprimer avec des relations
Importez Stage sous Concert dans routes/web.php. Ana Lopes se produit désormais sous le nom « Ana Lopes Ferreira ». Son nom n'est écrit qu'une fois, dans artists :
Route::get('/bac-a-sable', function () {
Artist::find(1)->update(['name' => 'Ana Lopes Ferreira']);
return redirect()->route('concerts.show', 2);
});
<h2>Invités</h2>
<ul>
<li><a href="http://premier-test-laravel.test/artistes/1">Ana Lopes Ferreira</a>, chant</li>
<li><a href="http://premier-test-laravel.test/artistes/2">Karim Benali</a>, oud</li>
</ul>
Une seule ligne a changé, et la fiche du concert 1 affiche aussi le nouveau nom. Vérifiez maintenant les cascades. La table pivot n'a pas de modèle : DB::table('artist_concert') l'interroge directement, et count() compte ses lignes.
Route::get('/bac-a-sable', function () {
dump(DB::table('artist_concert')->count());
Artist::find(1)->delete();
dump(DB::table('artist_concert')->count());
dd(Concert::count());
});
44 // routes/web.php:191
42 // routes/web.php:195
23 // routes/web.php:196
Ana Lopes était invitée à deux concerts. La base a supprimé ses deux paires, et les vingt-trois concerts restent. Supprimez maintenant un concert :
Route::get('/bac-a-sable', function () {
dump(DB::table('artist_concert')->count());
Concert::find(2)->delete();
dump(DB::table('artist_concert')->count());
dd(Artist::count());
});
42 // routes/web.php:191
41 // routes/web.php:195
12 // routes/web.php:196
La paire de Karim Benali au concert 2 a disparu, et les douze artistes restent. Supprimez enfin la Grande scène, comme au chapitre 11 :
Route::get('/bac-a-sable', function () {
dump(Concert::count());
dump(DB::table('artist_concert')->count());
Stage::where('name', 'Grande scène')->first()->delete();
dump(Concert::count());
dd(DB::table('artist_concert')->count());
});
22 // routes/web.php:191
41 // routes/web.php:192
11 // routes/web.php:196
20 // routes/web.php:197
La clé étrangère stage_id de concerts fait supprimer les onze concerts de la scène. La clé concert_id d'artist_concert fait ensuite supprimer les paires de chacun de ces concerts. Il reste les onze concerts du Chapiteau et leurs vingt paires, et aucun artiste n'est supprimé.
| Vous supprimez | La base supprime aussi |
|---|---|
| un artiste | ses paires dans artist_concert |
| un concert | ses paires dans artist_concert |
| une scène | ses concerts, puis leurs paires |
Remettez le festival en état :
php artisan migrate:fresh --seed
La sortie est celle de la section 4. Supprimez enfin la route /bac-a-sable et les imports d'Artist, Concert, Stage et DB dans routes/web.php, comme aux chapitres 10 et 11.
À retenir
- Une relation plusieurs à plusieurs se range dans une table pivot, dont la clé primaire composée interdit d'enregistrer deux fois la même paire.
belongsToMany()déduit tout des noms des deux classes : la table pivot les réunit dans l'ordre alphabétique, et chaque colonne ajoute_idà l'un d'eux.attach()ajoute des paires,detach()en retire, etsync()rend les paires du concert identiques à la liste reçue.- Une boucle qui lit une relation envoie une requête par élément, et
with()remplace ces requêtes par une seule. - Supprimer un artiste ou un concert supprime ses paires dans la table pivot, jamais l'artiste ou le concert d'en face.
Exercices
Comptez 85 minutes pour les exercices, en séance ou à la maison, et 20 minutes de plus pour le bonus.
Les katas 12.1 à 12.5 se font dans votre dépôt eloquent-26, à la suite des katas du chapitre 11. Les films y reçoivent des genres : une table genres et une table pivot genre_movie. Lancez les tests du chapitre :
php artisan test --group=chapitre12
Au départ, les 14 tests sont rouges. Les groupes chapitre10 et chapitre11 doivent rester verts : après chaque kata, lancez php artisan test --group=chapitre10, puis php artisan test --group=chapitre11.
12.1 La table pivot (15 minutes)
Créez la migration create_genres_table, avec une colonne name en plus de id() et timestamps(). Créez ensuite create_genre_movie_table, avec deux clés étrangères qui suppriment leurs lignes en cascade, et la paire comme clé primaire. Appliquez-les. Inspirez-vous de la section 2.
php artisan make:migration create_genres_table
php artisan make:migration create_genre_movie_table
| Test | Ce que vous écrivez |
|---|---|
| les tables genres (name) et genre_movie (genre_id, movie_id) existent | les deux Schema::create() |
| la même paire genre-film ne peut pas être enregistrée deux fois (clé primaire composée) | $table->primary(['genre_id', 'movie_id']) |
| genre_movie refuse un film ou un genre qui n'existe pas (clés étrangères) | foreignId(…)->constrained() pour les deux colonnes |
| supprimer un film retire ses lignes de genre_movie (cascadeOnDelete) | ->cascadeOnDelete() |
12.2 belongsToMany (10 minutes)
Créez le modèle Genre avec #[Fillable]. Ajoutez movies() dans Genre et genres() dans Movie. Inspirez-vous de la section 3.
php artisan make:model Genre
| Test | Ce que vous écrivez |
|---|---|
| attach() relie un film à deux genres : $movie->genres en compte 2 | genres(), qui renvoie $this->belongsToMany(Genre::class) |
| un genre connaît ses films : $genre->movies contient le film | movies(), qui renvoie $this->belongsToMany(Movie::class) |
| sync([]) détache tous les genres d'un film | rien de plus |
12.3 Le seeder (15 minutes)
Créez GenreSeeder, importez-y Genre, et écrivez les six genres : Animation, Comédie, Documentaire, Drame, Science-fiction, Thriller.
php artisan make:seeder GenreSeeder
Dans DatabaseSeeder, importez Genre et appelez GenreSeeder après la création des films. Donnez ensuite à chaque film un à trois genres au hasard. La fonction PHP rand(1, 3) renvoie un entier entre les deux bornes. Inspirez-vous de ArtistSeeder, à la section 4 :
$genres = Genre::all();
foreach (Movie::all() as $movie) {
$movie->genres()->attach($genres->random(rand(1, 3)));
}
| Test | Ce que vous écrivez |
|---|---|
| GenreSeeder crée les six genres de l'énoncé | six Genre::create([...]), ou une boucle sur un tableau de noms |
| après DatabaseSeeder, il y a six genres et chaque film en a entre un et trois | $this->call(GenreSeeder::class); et la boucle ci-dessus |
12.4 La page genre (20 minutes)
Créez GenreController avec une méthode show(), et la vue genres.show. La page affiche le nom du genre et ses films. Dans routes/web.php, reliez GET /genres/{id} à GenreController::show(), ajoutez whereNumber('id') et nommez la route genres.show. Inspirez-vous d'ArtistController, à la section 5.
php artisan make:controller GenreController
php artisan make:view genres.show
| Test | Ce que vous écrivez |
|---|---|
| la page /genres/{id} affiche le nom du genre et ses films, et seulement eux | $genre->movies()->orderBy('title')->get() et un @foreach |
| un numéro de genre qui n'existe pas répond 404 | Genre::findOrFail($id) |
| la route /genres/{id} s'appelle genres.show | ->name('genres.show') |
12.5 Les genres dans la liste (10 minutes)
Sur la page /films, chaque ligne du tableau affiche les genres du film. Chargez-les dans MovieController::index() avec with(). Inspirez-vous de la section 6.
| Test | Ce que vous écrivez |
|---|---|
| la page /films affiche les genres de chaque film | un @foreach ($movie->genres as $genre) dans la ligne du film |
| la page /films charge les genres avec with() : au plus 3 requêtes SQL pour toute la page | Movie::with('genres')->orderBy('title') |
12.6 Bonus : les salles (20 minutes)
Chaque séance a lieu dans une salle. Créez la table rooms, avec name et seats, le nombre de places, et le modèle Room.
Ajoutez à showtimes une clé étrangère room_id avec nullable(), car les séances déjà enregistrées, et celles des tests du chapitre 11, n'ont pas de salle. Ajoutez la relation room() dans Showtime, sur le modèle du chapitre 11.
Sur la page /seances, affichez la salle avec $showtime->room?->name, l'opérateur ?-> du chapitre 5 de la POO. Dans ShowtimeController::index(), chargez le film et la salle avec with(['movie', 'room']). Ces tests forment un groupe à part :
php artisan make:migration create_rooms_table
php artisan make:migration add_room_id_to_showtimes_table --table=showtimes
php artisan make:model Room
php artisan test --group=bonus
| Test | Ce que vous écrivez |
|---|---|
| bonus : la table rooms (name, seats) existe et showtimes a une colonne room_id | string('name'), unsignedSmallInteger('seats'), foreignId('room_id')->nullable()->constrained() |
| bonus : une séance connaît sa salle : $showtime->room->name | room(), qui renvoie $this->belongsTo(Room::class) |
| bonus : la page /seances affiche la salle de chaque séance | {{ $showtime->room?->name }} dans la ligne de la séance |
12.7 Des tables et des requêtes (sur papier, 15 minutes)
- L'application d'une bibliothèque enregistre des livres et leurs auteurs. Un livre a un ou plusieurs auteurs, et un auteur a écrit un ou plusieurs livres. Dessinez les trois tables avec leurs colonnes et deux lignes d'exemple, en donnant à la table pivot le nom attendu par Laravel. Dessinez ensuite le diagramme de classes de
BooketAuthor. - La base contient ce que le seeder de la section 4 y a mis : deux scènes, vingt-trois concerts et treize artistes. Combien de requêtes chaque extrait envoie-t-il, et quelles relations
with()devrait-il charger ?
// Extrait A
$stage = Stage::find(1);
foreach ($stage->concerts as $concert) {
echo $concert->artist;
}
// Extrait B
foreach (Artist::all() as $artist) {
echo $artist->concerts->count();
}
// Extrait C
foreach (Concert::with('guests')->get() as $concert) {
echo $concert->stage->name . ' : ' . $concert->guests->count();
}