Aller au contenu

Modèles : plusieurs à plusieurs

Bloc Laravel · séance 5 · ~50 min

Slides du chapitre (PDF)PDF

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 :

  1. expliquer pourquoi une relation plusieurs à plusieurs demande une table pivot, et écrire sa migration,
  2. déclarer une relation belongsToMany des deux côtés, et la dessiner dans un diagramme de classes,
  3. relier et délier des lignes avec attach, detach et sync,
  4. compter les requêtes d'une boucle, et les réduire avec with(),
  5. 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.

La table artist_concert relie trois artistes à trois concerts par quatre paires de numéros

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 comme stage_id au 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 de timestamps(), 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 noms Artist et Concert rangés dans l'ordre alphabétique,
  • la colonne de la classe qui déclare la relation, artist_id, le nom Artist en 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" = 2 garde les paires du concert 2,
  • inner join "artist_concert" on "artists"."id" = "artist_concert"."artist_id" associe à chaque paire la ligne d'artists dont l'id vaut artist_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 les id.

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&#039;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.

À gauche, la boucle sans with() envoie 24 requêtes. À droite, with('stage') en envoie 2

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, et sync() 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)

  1. 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 Book et Author.
  2. 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();
}

12.8 Six cartes

Pourquoi une table pivot ?

Parce qu'un concert a plusieurs invités et qu'un invité vient à plusieurs concerts. Une case de concerts ou d'artists ne peut contenir qu'une clé étrangère, alors que chaque ligne de la table pivot en contient une paire.

Comment Laravel nomme-t-il la table pivot de Artist et Concert ?

artist_concert : les deux noms de modèles au singulier, en minuscules, dans l'ordre alphabétique, reliés par _.

Que refuse la clé primaire composée de la table pivot ?

Une seconde ligne avec la même paire (artist_id, concert_id). La base répond UNIQUE constraint failed.

attach() ou sync() ?

attach() ajoute des paires, et échoue si l'une existe déjà. sync() reçoit la liste complète, ajoute ce qui manque et retire le reste.

Qu'est-ce que le piège N+1 ?

Une requête pour lire une liste, puis une requête de plus par élément quand la boucle lit une relation. Avec 23 concerts, 24 requêtes.

Que fait Concert::with('stage')->get() ?

Il lit les concerts, puis toutes leurs scènes en une seule requête avec where "id" in (…). Dans la boucle, $concert->stage n'envoie plus de requête.