Aller au contenu

Modèles : deux tables

Bloc Laravel · séance 4 · ~50 min

Slides du chapitre (PDF)PDF

Au chapitre 10, chaque concert recopie le nom de sa scène, en toutes lettres, dans la colonne stage. Dans ce chapitre, les scènes reçoivent leur propre table, stages, et chaque concert ne garde que le numéro de sa scène.

À la fin de ce chapitre, vous serez capables de :

  1. écrire une migration qui ajoute une clé étrangère à une table existante,
  2. déclarer une relation avec hasMany et belongsTo, et la lire dans un diagramme de classes,
  3. remplir la base avec des fabriques et Faker,
  4. lire les données liées avec $concert->stage et $stage->concerts,
  5. afficher une page qui liste les concerts d'une scène.

1. Le nom de la scène, recopié

Recréez la route bac à sable du chapitre 10, avec l'import de Concert :

use App\Models\Concert;
Route::get('/bac-a-sable', function () {
    dd(Concert::where('stage', 'Grande scène')->count());
});
2 // routes/web.php:182

Deux concerts se jouent sur la Grande scène : L'Orchestre du Lac et Orage Mécanique. Ajoutez maintenant un concert dont le nom de scène porte une majuscule de trop :

Route::get('/bac-a-sable', function () {
    Concert::create([
        'artist' => 'Les Cordes Sauvages',
        'stage' => 'Grande Scène',
        'starts_at' => '2026-10-16 21:00:00',
        'duration' => 60,
        'description' => 'Violons et contrebasse.',
    ]);

    dump(Concert::count());
    dd(Concert::where('stage', 'Grande scène')->count());
});
4 // routes/web.php:190

2 // routes/web.php:191

Chaque rechargement ajoute un concert : ouvrez la page une seule fois. La table contient quatre concerts, mais where() n'en trouve toujours que deux sur la Grande scène. SQLite compare les textes caractère par caractère, et S n'est pas s. La page /concerts affiche maintenant trois noms de scène : Chapiteau, Grande scène et Grande Scène.

Le code qui sent mauvais

En binôme, cinq minutes. L'organisatrice renomme la Grande scène en « Scène du Lac ». Elle veut aussi afficher sur la fiche de chaque concert la capacité de sa scène, 3 000 places pour celle-ci. Avec la table concerts du chapitre 10, que devez-vous modifier pour satisfaire ces deux demandes ?

Aux chapitres 4 et 5 de la POO, vous avez relié deux classes par un trait, avec un rôle et une multiplicité à chaque bout. Ici, une scène accueille plusieurs concerts, et chaque concert se joue sur une seule scène. Vous écrivez maintenant ce lien dans la base, puis dans les modèles.

2. La table des scènes et la clé étrangère

Créez d'abord la table des scènes :

php artisan make:migration create_stages_table
   INFO  Migration [database/migrations/2026_09_28_220036_create_stages_table.php] created successfully.
Schema::create('stages', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->unsignedSmallInteger('capacity');
    $table->string('location');
    $table->timestamps();
});

capacity compte des places, et location situe la scène dans le parc.

Une migration qui modifie concerts

La table concerts existe déjà : pour la changer, écrivez une nouvelle migration, comme au chapitre 10. Artisan reconnaît la fin _to_concerts_table du nom, et l'option --table=concerts le dit en toutes lettres :

php artisan make:migration add_stage_id_to_concerts_table --table=concerts
   INFO  Migration [database/migrations/2026_09_28_220038_add_stage_id_to_concerts_table.php] created successfully.

Le fichier créé appelle Schema::table('concerts', …) dans up() et dans down(). Cet appel modifie une table existante, là où Schema::create() en crée une. Remplacez le commentaire vide des deux méthodes :

public function up(): void
{
    Schema::table('concerts', function (Blueprint $table) {
        $table->foreignId('stage_id')->constrained()->cascadeOnDelete();
        $table->dropColumn('stage');
    });
}

public function down(): void
{
    Schema::table('concerts', function (Blueprint $table) {
        $table->dropConstrainedForeignId('stage_id');
        $table->string('stage');
    });
}
  • foreignId('stage_id') ajoute une colonne d'entiers, stage_id, qui contiendra l'id d'une ligne de stages. Si la Grande scène porte l'id 2, ses concerts auront un stage_id égal à 2. Une colonne qui contient l'identifiant d'une ligne d'une autre table s'appelle une clé étrangère.
  • constrained() ajoute une contrainte : la base refusera un stage_id qui ne correspond à aucune ligne de stages. Laravel trouve le nom de cette table en retirant _id du nom de la colonne, puis en mettant le reste au pluriel.
  • cascadeOnDelete() demande à la base de supprimer aussi les concerts d'une scène supprimée.
  • dropColumn('stage') supprime la colonne de texte, remplacée par la clé.
  • down() fait l'inverse : dropConstrainedForeignId('stage_id') retire la clé et sa colonne, puis string('stage') remet la colonne de texte.

Pour le festival, un concert n'existe pas sans sa scène : si le Chapiteau n'est pas monté, ses concerts n'ont pas lieu. cascadeOnDelete() écrit cette règle dans la base.

Une table déjà remplie

Appliquez les deux migrations :

php artisan migrate
   INFO  Running migrations.

  2026_09_28_220036_create_stages_table .......................... 7.71ms DONE
  2026_09_28_220038_add_stage_id_to_concerts_table ............... 4.15ms FAIL

   Illuminate\Database\QueryException

  SQLSTATE[HY000]: General error: 1 Cannot add a NOT NULL column with default value NULL (Connection: sqlite, …, SQL: alter table "concerts" add column "stage_id" integer not null)
…

La table stages existe, mais la seconde migration a échoué. Les quatre concerts de la table recevraient null dans la nouvelle colonne. Or stage_id, comme toute colonne sans nullable(), refuse null : SQLite refuse donc de l'ajouter. migrate:status montre l'état exact (sortie raccourcie) :

php artisan migrate:status
  Migration name .............................................. Batch / Status
…
  2026_09_28_213249_create_concerts_table ............................ [1] Ran
  2026_09_28_220036_create_stages_table .............................. [2] Ran
  2026_09_28_220038_add_stage_id_to_concerts_table ................... Pending

Videz la base et appliquez toutes les migrations avec migrate:fresh, cette fois sans --seed : ConcertSeeder écrit encore dans la colonne stage, qui va disparaître. Le seeder, réécrit plus loin, recréera les trois concerts du festival avec leur scène (sortie raccourcie).

php artisan migrate:fresh
  Dropping all tables ............................................ 3.50ms DONE
…
  2026_09_28_213249_create_concerts_table ........................ 3.84ms DONE
  2026_09_28_220036_create_stages_table .......................... 3.62ms DONE
  2026_09_28_220038_add_stage_id_to_concerts_table .............. 24.25ms DONE

Sur une table vide, aucune ligne ne doit recevoir de stage_id, et l'ajout réussit. Laravel trie les migrations par leur nom, qui commence par leur date de création : create_stages_table passe en premier, et stages existe avant la clé.

Attention

Sur une table remplie, migrate:rollback échoue aussi : down() recrée la colonne stage, qui refuse null comme stage_id, et SQLite affiche la même erreur. Dans ce chapitre, revenez en arrière avec migrate:fresh, jamais sur une base dont vous gardez les données.

php artisan db:table concerts
  main.concerts ..............................................................
  Columns .................................................................. 8

  Column ................................................................ Type
  id integer, autoincrement .......................................... integer
  artist varchar ..................................................... varchar
  starts_at datetime ................................................ datetime
…
  stage_id integer ................................................... integer
…
  Foreign Key .......................................... On Update / On Delete
  stage_id references id on stages ....................... no action / cascade

La colonne stage a disparu, et stage_id s'est ajoutée en dernière position. La ligne Foreign Key se lit ainsi : stage_id désigne la colonne id de stages, et la suppression d'une scène se propage à ses concerts, cascade.

La colonne stage_id de chaque concert contient l'id d'une ligne de la table stages

3. Deux modèles, une relation

Créez le modèle des scènes :

php artisan make:model Stage
   INFO  Model [app/Models/Stage.php] created successfully.

Donnez à Stage sa liste #[Fillable] et une méthode concerts() :

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

#[Fillable(['name', 'capacity', 'location'])]
class Stage extends Model
{
    public function concerts(): HasMany
    {
        return $this->hasMany(Concert::class);
    }
}

La méthode concerts() est une relation Eloquent : une méthode de modèle qui décrit comment retrouver, dans une autre table, les lignes liées à l'objet.

Dans Concert, remplacez 'stage' par 'stage_id' dans #[Fillable]. Ajoutez l'import de BelongsTo sous les imports existants, puis la méthode stage() sous casts() :

use Illuminate\Database\Eloquent\Relations\BelongsTo;

#[Fillable(['artist', 'stage_id', 'starts_at', 'duration', 'description'])]
class Concert extends Model
{
    protected function casts(): array
    {
        return [
            'starts_at' => 'datetime',
        ];
    }

    public function stage(): BelongsTo
    {
        return $this->belongsTo(Stage::class);
    }
}

Chaque méthode renvoie un objet qui prépare la recherche des lignes liées. Sa classe, HasMany ou BelongsTo, sert de type de retour.

  • hasMany(Concert::class) se lit « une scène a plusieurs concerts ». Pour trouver les concerts d'une scène, Eloquent cherche dans la table concerts les lignes dont stage_id vaut l'id de la scène. Il déduit le nom stage_id du nom de la classe Stage, écrit comme le nom de sa table au singulier, stage, et suivi de _id. ParkingRate donnerait parking_rate_id.
  • belongsTo(Stage::class) se lit « un concert appartient à une scène ». Eloquent lit le stage_id du concert, puis cherche dans la table stages la ligne dont l'id vaut ce nombre. Il déduit le nom stage_id du nom de la méthode, stage, suivi de _id.

Les deux déductions donnent stage_id, le nom choisi dans la migration : vous n'écrivez aucun nom de colonne. Une méthode appelée scene() chercherait une colonne scene_id, qui n'existe pas.

La classe Concert n'a ni propriété ni colonne stage. Quand vous lisez $concert->stage, sans parenthèses, la méthode magique __get(), vue au chapitre 3 de la POO, appelle donc stage() et renvoie la scène trouvée. $stage->concerts fonctionne de la même façon.

hasMany cherche dans concerts, belongsTo cherche dans stages, et les deux passent par stage_id

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]
}
Stage "1 +stage" *-- "0..* +concerts" Concert : accueille >
@enduml

Le trait se lit « une scène accueille zéro, un ou plusieurs concerts », et chaque concert a exactement une scène. Chaque bout porte le rôle que le code utilise : concerts est le nom que Stage donne à ses concerts, et stage celui que Concert donne à sa scène.

Comme au chapitre 5 de la POO, chaque rôle revient dans la boîte de la classe qui le porte. stage_id n'apparaît pas : dans un diagramme de classes, c'est le trait qui relie les deux classes.

Le losange plein {uml:composition}, collé à Stage, marque une composition : un concert n'existe pas sans sa scène et disparaît avec elle, comme le sac du héros. cascadeOnDelete() est la traduction, en base, de la composition dessinée au chapitre 5 de la POO.

La base vérifie la clé

La table stages est encore vide. Dans la route bac à sable, essayez d'enregistrer un concert sur la scène 1 :

Route::get('/bac-a-sable', function () {
    Concert::create([
        'stage_id' => 1,
        'artist' => 'Les Cordes Sauvages',
        'starts_at' => '2026-10-16 21:00:00',
        'duration' => 60,
        'description' => 'Violons et contrebasse.',
    ]);

    return 'Concert enregistré';
});
Illuminate\Database\QueryException
SQLSTATE[23000]: Integrity constraint violation: 19 FOREIGN KEY constraint failed (Connection: sqlite, …, SQL: insert into "concerts" ("stage_id", "artist", "starts_at", "duration", "description", "updated_at", "created_at") values (1, Les Cordes Sauvages, …))

La base a refusé la ligne, car aucune scène ne porte l'id 1. C'est constrained() qui a posé cette règle : une clé étrangère désigne toujours une ligne qui existe.

4. Les fabriques et Faker

Le programme d'un vrai festival compte une vingtaine de concerts, soit vingt appels à Concert::create(). Une fabrique, vue au chapitre Structure, est une classe qui produit des objets d'un modèle avec des valeurs inventées. DatabaseSeeder utilise déjà UserFactory quand il appelle User::factory() pour créer Test User. Créez une fabrique par modèle :

php artisan make:factory StageFactory --model=Stage
php artisan make:factory ConcertFactory --model=Concert
   INFO  Factory [database/factories/StageFactory.php] created successfully.

   INFO  Factory [database/factories/ConcertFactory.php] created successfully.
<?php

namespace Database\Factories;

use App\Models\Stage;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends Factory<Stage>
 */
class StageFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            //
        ];
    }
}

StageFactory hérite de Factory, et le commentaire @extends Factory<Stage> indique à l'éditeur le modèle produit.

La méthode definition() renvoie un tableau avec une clé par colonne, comme celui que vous passez à create(). La fabrique l'appelle une fois pour chaque objet à produire. Remplissez-la :

public function definition(): array
{
    return [
        'name' => fake()->randomElement(['Kiosque', 'Clairière', 'Verger', 'Serre']),
        'capacity' => fake()->numberBetween(100, 2000),
        'location' => fake()->streetName(),
    ];
}

La fonction fake() renvoie un objet de Faker, une bibliothèque installée pour le développement dans chaque projet Laravel, qui invente des données. Chaque appel donne une nouvelle valeur :

Vous écrivez Faker renvoie
fake()->randomElement([...]) un élément du tableau, pris au hasard
fake()->numberBetween(100, 2000) un entier entre les deux bornes comprises
fake()->streetName() un nom de rue
fake()->name() un prénom et un nom
fake()->dateTimeBetween('…', '…') une date et une heure entre deux moments
fake()->sentence() une phrase de faux latin

Dans routes/web.php, importez Stage sous Concert, et demandez trois scènes à la fabrique :

Route::get('/bac-a-sable', function () {
    dd(Stage::factory()->count(3)->create());
});
BadMethodCallException
Call to undefined method App\Models\Stage::factory()

La méthode statique factory() vient du trait HasFactory, et les traits sont vus au chapitre 7 de la POO. User utilise déjà ce trait, mais make:model ne l'ajoute pas. Collez-le dans Stage, et de la même façon dans Concert :

use Illuminate\Database\Eloquent\Factories\HasFactory;

#[Fillable(['name', 'capacity', 'location'])]
class Stage extends Model
{
    use HasFactory;

    // concerts() ne change pas
}

Rechargez la page, puis dépliez le premier élément et ses #attributes (sortie raccourcie) :

Illuminate\Database\Eloquent\Collection {#1565 ▼ // routes/web.php:183
  #items: array:3 [▼
    0 => App\Models\Stage {#1548 ▼
…
      #attributes: array:6 [▼
        "name" => "Verger"
        "capacity" => 472
        "location" => "Lane Harbors"
        "updated_at" => "2026-09-28 22:42:15"
        "created_at" => "2026-09-28 22:42:15"
        "id" => 1
      ]
…

count(3) demande trois objets, et create() les enregistre puis les renvoie dans une collection. factory() trouve la classe StageFactory d'après le nom du modèle, suivi de Factory. Chez vous, les valeurs sont différentes, et chaque rechargement ajoute trois scènes.

La langue des données

« Lane Harbors » est une rue américaine. La ligne APP_FAKER_LOCALE du fichier .env fixe la langue et le pays des données de Faker, et vaut en_US dans un projet neuf. Passez au français de Belgique :

APP_FAKER_LOCALE=fr_BE

Rechargez la page. Les trois nouvelles scènes, numérotées de 4 à 6, ont des noms de rues belges (extraits) :

        "location" => "chemin Lecomte"
…
        "location" => "impasse Claeys"
…
        "location" => "place Dierckx"

Les noms de personnes suivent aussi ce réglage, mais sentence() reste en faux latin.

Une fabrique qui crée sa scène

Remplissez la fabrique des concerts, et importez Stage sous Concert :

public function definition(): array
{
    return [
        'stage_id' => Stage::factory(),
        'artist' => fake()->name(),
        'starts_at' => fake()->dateTimeBetween('2026-10-16 17:00', '2026-10-16 23:00')->format('Y-m-d H:00:00'),
        'duration' => fake()->randomElement([45, 60, 75, 90]),
        'description' => fake()->sentence(),
    ];
}
  • 'stage_id' => Stage::factory() donne une fabrique au lieu d'un nombre. Au moment d'enregistrer le concert, Laravel lance la fabrique des scènes, enregistre une nouvelle scène, puis place son id dans stage_id.
  • starts_at reçoit une heure du vendredi soir, entre 17 h et 23 h. dateTimeBetween() renvoie un objet date, dont la méthode format() fonctionne comme celle de Carbon au chapitre 10. Y-m-d écrit la date, H l'heure sur deux chiffres, et 00:00 est recopié tel quel.
Route::get('/bac-a-sable', function () {
    dump(Stage::count());
    $concert = Concert::factory()->create();
    dump(Stage::count());
    dd($concert);
});

Dépliez #attributes (sortie raccourcie) :

6 // routes/web.php:183

7 // routes/web.php:185

App\Models\Concert {#1464 ▼ // routes/web.php:186
…
  #attributes: array:8 [▼
    "stage_id" => 7
    "artist" => "Antoine Renard"
    "starts_at" => "2026-10-16 22:00:00"
    "duration" => 90
    "description" => "Tempore repellat non sed reprehenderit."
…

La table comptait six scènes, celles des deux essais précédents. Pour un seul concert, la fabrique a créé une septième scène, et le concert a reçu son id, 7.

5. Le seeder avec les fabriques

Créez StageSeeder, qui enregistre les deux vraies scènes du festival :

php artisan make:seeder StageSeeder
   INFO  Seeder [database/seeders/StageSeeder.php] created successfully.

Importez App\Models\Stage en haut du fichier, puis remplissez run() :

public function run(): void
{
    Stage::create([
        'name' => 'Chapiteau',
        'capacity' => 400,
        'location' => 'Pelouse est',
    ]);

    Stage::create([
        'name' => 'Grande scène',
        'capacity' => 3000,
        'location' => 'Bord du lac',
    ]);
}

Dans ConcertSeeder, chaque concert reçoit le numéro de sa scène, retrouvée par son nom avec where() et first(). Importez Stage sous Concert, et remplacez la méthode run() :

public function run(): void
{
    $tent = Stage::where('name', 'Chapiteau')->first();
    $mainStage = Stage::where('name', 'Grande scène')->first();

    Concert::create([
        'stage_id' => $tent->id,
        'artist' => 'Duo Mistral',
        'starts_at' => '2026-10-16 18:00:00',
        'duration' => 45,
        'description' => 'Guitare et violon, <strong>entrée libre</strong>.',
    ]);

    Concert::create([
        'stage_id' => $mainStage->id,
        'artist' => "L'Orchestre du Lac",
        // … starts_at, duration et description du chapitre 10
    ]);

    Concert::create([
        'stage_id' => $mainStage->id,
        'artist' => 'Orage Mécanique',
        // … starts_at, duration et description du chapitre 10
    ]);

    foreach (Stage::all() as $stage) {
        Concert::factory()->count(10)->create(['stage_id' => $stage->id]);
    }
}

La boucle demande dix concerts de fabrique pour chaque scène. Le tableau passé à create() remplace les valeurs de definition() : chaque concert reçoit l'id de la scène en cours, sans lancer Stage::factory(). Dans DatabaseSeeder, appelez StageSeeder avant ConcertSeeder :

$this->call(StageSeeder::class);
$this->call(ConcertSeeder::class);

Dans l'ordre inverse, la table stages serait vide au moment où ConcertSeeder la lit. first() renverrait null, et le seeder s'arrêterait sur Attempt to read property "id" on null. Remettez la base à neuf (sortie raccourcie) :

php artisan migrate:fresh --seed
…
  2026_09_28_220038_add_stage_id_to_concerts_table .............. 24.55ms DONE


   INFO  Seeding database.

  Database\Seeders\StageSeeder ....................................... RUNNING
  Database\Seeders\StageSeeder ..................................... 8 ms DONE

  Database\Seeders\ConcertSeeder ..................................... RUNNING
  Database\Seeders\ConcertSeeder .................................. 92 ms DONE

Comptez les lignes dans la route bac à sable :

Route::get('/bac-a-sable', function () {
    dump(Stage::count());
    dd(Concert::count());
});
2 // routes/web.php:183

23 // routes/web.php:184

La base contient deux scènes et vingt-trois concerts : les trois du chapitre 10, puis dix concerts de fabrique pour chacune des deux scènes. Écrivez à la main les concerts que les pages doivent toujours montrer, et laissez une fabrique inventer les lignes dont les valeurs importent peu.

6. Lire à travers la relation

Lisez la scène du concert 1, Duo Mistral :

Route::get('/bac-a-sable', function () {
    $concert = Concert::find(1);

    dump($concert->stage_id);
    dump($concert->stage->name);
    dump($concert->stage);
    dump($concert->stage());
    dd($concert->stage()->toRawSql());
});

Dépliez #attributes dans le premier objet (sortie raccourcie) :

1 // routes/web.php:185

"Chapiteau" // routes/web.php:186

App\Models\Stage {#1095 ▼ // routes/web.php:187
…
  #attributes: array:6 [▼
    "id" => 1
    "name" => "Chapiteau"
    "capacity" => 400
…
Illuminate\Database\Eloquent\Relations\BelongsTo {#1526 ▼ // routes/web.php:188
…
  #foreignKey: "stage_id"
  #ownerKey: "id"
…
"select * from "stages" where "stages"."id" = 1" // routes/web.php:189
  • $concert->stage, sans parenthèses, passe par __get(). À la première lecture, Eloquent envoie la requête à la base et renvoie un objet Stage. Il garde ensuite cet objet : une seconde lecture de $concert->stage ne relance pas la requête.
  • $concert->stage(), avec parenthèses, appelle votre méthode. Elle renvoie la relation, un objet BelongsTo qui décrit la recherche sans l'envoyer : la clé étrangère stage_id et la colonne visée, id.

toRawSql() montre la requête de la relation : Eloquent a lu le stage_id du concert, 1, et cherche dans stages la ligne dont l'id vaut 1. Lisez la propriété pour afficher une donnée, et appelez la méthode pour compléter la requête.

De l'autre côté, $stage->concerts renvoie une collection. Remplacez le contenu de la route (sortie raccourcie) :

Route::get('/bac-a-sable', function () {
    $stage = Stage::find(1);

    dump($stage->concerts);
    dump($stage->concerts->count());
    dump($stage->concerts()->toRawSql());

    foreach ($stage->concerts()->orderBy('starts_at')->get() as $concert) {
        dump($concert->starts_at->format('G \h') . ' ' . $concert->artist);
    }
});
Illuminate\Database\Eloquent\Collection {#1118 ▼ // routes/web.php:185
  #items: array:11 [▶]
  #escapeWhenCastingToString: false
}

11 // routes/web.php:186

"select * from "concerts" where "concerts"."stage_id" = 1 and "concerts"."stage_id" is not null" // routes/web.php:187

"17 h Léa Bodart" // routes/web.php:190

"17 h Lars Urbain" // routes/web.php:190

"18 h Duo Mistral" // routes/web.php:190
…
"22 h Anna Poncelet" // routes/web.php:190

Le Chapiteau a onze concerts : Duo Mistral et dix concerts de fabrique. La requête de hasMany garde les lignes de concerts dont stage_id vaut 1, l'id du Chapiteau. La condition is not null est toujours vraie ici, car la colonne refuse null.

concerts() renvoie une relation, que vous complétez comme une requête du chapitre 10 : orderBy('starts_at') trie les concerts, et get() envoie la requête.

Vous écrivez Vous recevez
$concert->stage un objet Stage
$concert->stage() une relation BelongsTo, sans requête envoyée
$stage->concerts une collection d'objets Concert, peut-être vide
$stage->concerts() une relation HasMany, à compléter avec orderBy(), get() ou count()

Supprimer une scène

Vérifiez maintenant la cascade choisie dans la migration :

Route::get('/bac-a-sable', function () {
    dump(Concert::count());

    Stage::where('name', 'Grande scène')->first()->delete();

    dd(Concert::count());
});
23 // routes/web.php:183

11 // routes/web.php:187

Aucune ligne de PHP ne supprime de concert. La base a supprimé elle-même les douze concerts de la Grande scène, à cause de cascadeOnDelete(). Remettez le festival en état avec php artisan migrate:fresh --seed. Les concerts de fabrique reçoivent de nouveaux noms.

7. Les pages

Ouvrez le programme :

curl -s http://premier-test-laravel.test/concerts
…
            <article class="concert-card">
    <h2>Duo Mistral</h2>
    <p>{&quot;id&quot;:1,&quot;name&quot;:&quot;Chapiteau&quot;,&quot;capacity&quot;:400,&quot;location&quot;:&quot;Pelouse est&quot;,&quot;created_at&quot;:&quot;2026-09-28T22:43:26.000000Z&quot;,&quot;updated_at&quot;:&quot;2026-09-28T22:43:26.000000Z&quot;}, 18 h</p>
    </article>
…

Le composant concert-card écrit toujours {{ $concert->stage }}, qui renvoie maintenant un objet Stage. Pour l'écrire dans la page, {{ }} appelle sa méthode __toString(), vue au chapitre 1 de la POO. Celle d'un modèle renvoie ses colonnes au format JSON, vu au chapitre Controllers. Dans components/concert-card.blade.php, affichez le nom de la scène :

<p>{{ $concert->stage->name }}, {{ $concert->starts_at->format('G \h') }}</p>
…
            <article class="concert-card">
    <h2>Duo Mistral</h2>
    <p>Chapiteau, 18 h</p>
    </article>
        <a href="http://premier-test-laravel.test/concerts/1">Voir la fiche</a>
…
            <article class="concert-card">
    <h2>Kobe Hermans</h2>
    <p>Grande scène, 18 h</p>
            <p class="note">Entracte de 15 minutes</p>
    </article>
…

Les vingt-trois cartes restent triées par heure, et ConcertController n'a pas changé. Pour chaque carte, la lecture de $concert->stage envoie une requête à la table stages, avec le stage_id du concert.

Une page par scène

Créez le contrôleur et la vue de la page d'une scène :

php artisan make:controller StageController
php artisan make:view stages.show
   INFO  Controller [app/Http/Controllers/StageController.php] created successfully.

   INFO  View [resources/views/stages/show.blade.php] created successfully.

Remplacez le contenu du contrôleur :

<?php

namespace App\Http\Controllers;

use App\Models\Stage;
use Illuminate\View\View;

class StageController extends Controller
{
    public function show(int $id): View
    {
        $stage = Stage::findOrFail($id);
        $concerts = $stage->concerts()->orderBy('starts_at')->get();

        return view('stages.show', ['stage' => $stage, 'concerts' => $concerts]);
    }
}

findOrFail() répond 404 pour un numéro inconnu, comme au chapitre 10, et la méthode concerts() permet de trier les concerts par heure. Écrivez la vue :

<x-layouts.app :title="$stage->name">
    <h1>{{ $stage->name }}</h1>
    <p>{{ $stage->location }}, {{ $stage->capacity }} places</p>

    <h2>Concerts</h2>
    <ul>
        @forelse ($concerts as $concert)
            <li>
                {{ $concert->starts_at->format('G \h') }}
                <a href="{{ route('concerts.show', $concert->id) }}">{{ $concert->artist }}</a>
            </li>
        @empty
            <li>Aucun concert sur cette scène</li>
        @endforelse
    </ul>
</x-layouts.app>

Dans routes/web.php, importez StageController sous ConcertController, puis ajoutez la route au-dessus de la route bac à sable :

Route::get('/scenes/{id}', [StageController::class, 'show'])->whereNumber('id')->name('stages.show');
curl -s http://premier-test-laravel.test/scenes/1
…
    <title>Chapiteau · Les Nuits du Parc</title>
…
        <h1>Chapiteau</h1>
    <p>Pelouse est, 400 places</p>

    <h2>Concerts</h2>
    <ul>
                    <li>
                18 h
                <a href="http://premier-test-laravel.test/concerts/1">Duo Mistral</a>
            </li>
                    <li>
                18 h
                <a href="http://premier-test-laravel.test/concerts/7">Antoine Verhaeghe</a>
            </li>
…

/scenes/9 répond 404. La commande route:list confirme le nom de la route :

php artisan route:list --path=scenes
  GET|HEAD       scenes/{id} .................................. stages.show › StageController@show

                                                                                Showing [1] routes

Sur la fiche d'un concert, faites du nom de la scène un lien. Dans concerts/show.blade.php, remplacez le paragraphe sous le <h1> :

<p>
    <a href="{{ route('stages.show', $concert->stage_id) }}">{{ $concert->stage->name }}</a>,
    {{ $concert->starts_at->format('G \h') }}
</p>
curl -s http://premier-test-laravel.test/concerts/2
…
    <h1>L&#039;Orchestre du Lac</h1>
    <p>
        <a href="http://premier-test-laravel.test/scenes/2">Grande scène</a>,
        20 h
    </p>
…

Le lien utilise $concert->stage_id, le numéro de scène que le concert contient déjà. Pour voir le bloc @empty, créez une scène sans concert dans la route bac à sable, puis envoyez le navigateur vers sa page avec redirect(), vu au chapitre Routing :

Route::get('/bac-a-sable', function () {
    $stage = Stage::create([
        'name' => 'Kiosque',
        'capacity' => 80,
        'location' => 'Entrée sud',
    ]);

    return redirect()->route('stages.show', $stage->id);
});

Ouvrez /bac-a-sable. Le navigateur arrive sur /scenes/3 :

    <main>
        <h1>Kiosque</h1>
    <p>Entrée sud, 80 places</p>

    <h2>Concerts</h2>
    <ul>
                    <li>Aucun concert sur cette scène</li>
            </ul>
    </main>

Pour finir, supprimez la route /bac-a-sable et les imports de Concert et de Stage dans routes/web.php, comme au chapitre 10. Relancez php artisan migrate:fresh --seed pour retirer le Kiosque.

À retenir

  • Une clé étrangère comme stage_id contient l'id d'une ligne d'une autre table, et constrained() interdit une valeur qui ne désigne aucune ligne.
  • cascadeOnDelete() fait supprimer par la base les concerts d'une scène supprimée.
  • hasMany() cherche les concerts dont stage_id vaut l'id de la scène, et belongsTo() cherche la scène dont l'id vaut le stage_id du concert.
  • $stage->concerts renvoie les concerts de la scène, alors que $stage->concerts() renvoie la relation, que vous complétez avec orderBy(), count() ou get().
  • Une fabrique invente les valeurs d'un modèle avec fake(), et le tableau passé à create() remplace celles que vous voulez fixer.

Exercices

Comptez 90 minutes pour les exercices, en séance ou à la maison.

Les katas 11.1 à 11.6 se font dans votre dépôt eloquent-26, à la suite des katas du chapitre 10. Les films y reçoivent leurs séances : une table showtimes reliée à movies. Lancez les tests du chapitre :

php artisan test --group=chapitre11

Au départ, les 16 tests sont rouges. Les tests du chapitre 10 doivent rester verts : relancez php artisan test --group=chapitre10 après chaque kata. Faites les katas dans l'ordre.

11.1 La table des séances (10 minutes)

Créez la migration create_showtimes_table. En plus de id() et timestamps(), elle ajoute trois colonnes :

  • movie_id, une clé étrangère vers movies, qui supprime les séances d'un film supprimé,
  • starts_at, une date avec l'heure,
  • price, un prix avec deux décimales.

Appliquez-la. Inspirez-vous de la section 2.

php artisan make:migration create_showtimes_table
Test Ce que vous écrivez
la table showtimes existe avec les colonnes movie_id, starts_at, price et les timestamps les trois colonnes dans up()
starts_at est une date avec heure et price un nombre décimal dateTime() et decimal('price', 5, 2)
une séance ne peut pas pointer vers un film qui n'existe pas (clé étrangère) foreignId('movie_id')->constrained()
supprimer un film supprime aussi ses séances (cascadeOnDelete) ->cascadeOnDelete()

11.2 Les deux relations (10 minutes)

Créez le modèle Showtime et déclarez ses trois colonnes, movie_id, starts_at et price, avec #[Fillable]. Donnez-lui la méthode casts() du chapitre 10, avec 'starts_at' => 'datetime'. Ajoutez ensuite movie() dans Showtime et showtimes() dans Movie. Inspirez-vous de la section 3.

php artisan make:model Showtime
Test Ce que vous écrivez
une séance connaît son film : $showtime->movie->title movie(), qui renvoie $this->belongsTo(Movie::class)
un film connaît ses séances : $movie->showtimes en compte 2 après deux créations showtimes(), qui renvoie $this->hasMany(Showtime::class)

11.3 Les fabriques (15 minutes)

Créez MovieFactory et ShowtimeFactory, puis ajoutez le trait HasFactory aux deux modèles. Un film de fabrique a un titre court, dure entre 70 et 180 minutes, et est sorti dans les vingt dernières années. Une séance de fabrique crée son film et coûte entre 6 et 12 euros. Elle a lieu dans les deux prochaines semaines, avec dateTimeBetween('now', '+2 weeks'). Inspirez-vous de la section 4.

php artisan make:factory MovieFactory --model=Movie
php artisan make:factory ShowtimeFactory --model=Showtime
Test Ce que vous écrivez
Movie::factory()->count(3)->create() crée trois films qui durent entre 70 et 180 minutes fake()->sentence(3) et fake()->numberBetween(70, 180)
les films de fabrique sont sortis dans les vingt dernières années fake()->dateTimeBetween('-20 years', 'now')->format('Y-m-d')
Showtime::factory() crée une séance pour le film donné, à un prix entre 6 et 12 'movie_id' => Movie::factory() et fake()->numberBetween(6, 12)

11.4 Le seeder avec fabriques (10 minutes)

Dans DatabaseSeeder, importez Movie et Showtime. Après l'appel de MovieSeeder, créez dix films de fabrique, puis trois séances pour chacun. Movie::factory()->count(10)->create() renvoie une collection : parcourez-la avec foreach, comme la boucle de ConcertSeeder. Inspirez-vous de la section 5, puis lancez php artisan migrate:fresh --seed.

Test Ce que vous écrivez
DatabaseSeeder crée 15 films et 30 séances la boucle, avec Showtime::factory()->count(3)->create([...])
après DatabaseSeeder, chaque film de fabrique a trois séances 'movie_id' => $movie->id dans le tableau de create()

11.5 Le programme des séances (20 minutes)

Créez ShowtimeController, avec une méthode index(), et la vue showtimes.index. Reliez-les à GET /seances, route nommée showtimes.index. La vue affiche toutes les séances, triées par heure de début, avec pour chacune le titre du film et le prix. Inspirez-vous de la section 7.

php artisan make:controller ShowtimeController
php artisan make:view showtimes.index
Test Ce que vous écrivez
la page /seances affiche les séances triées par heure de début, avec le titre du film Showtime::orderBy('starts_at')->get() et $showtime->movie->title
la page /seances affiche le prix de chaque séance $showtime->price
la route /seances s'appelle showtimes.index ->name('showtimes.index')

11.6 La fiche complétée (15 minutes)

La fiche /films/{id} du chapitre 10 liste maintenant les séances du film, triées par heure, et affiche « Aucune séance » quand il n'y en a pas. Affichez l'heure avec ses minutes : format('G \h i') écrit 14 h 15, car i donne les minutes sur deux chiffres. Inspirez-vous de StageController, à la section 7.

Test Ce que vous écrivez
la fiche d'un film liste ses séances $movie->showtimes()->orderBy('starts_at')->get(), passé à la vue
la fiche d'un film sans séance affiche « Aucune séance » @forelse et @empty

11.7 Prédire cinq lectures (sur papier, 10 minutes)

La base contient ce que le seeder de la section 5 y a mis : deux scènes et vingt-trois concerts. Écrivez ce que renvoie chaque ligne : un nombre, un texte, null, un objet, une relation ou une requête SQL.

Concert::find(2)->stage->name;
Concert::find(3)->stage_id;
Concert::find(1)->stage();
Stage::find(1)->concerts->count();
Concert::find(3)->stage()->toRawSql();

11.8 Six cartes

Que contient la colonne stage_id d'un concert ?

L'id de la ligne de stages qui représente sa scène. C'est une clé étrangère.

Que fait constrained() ?

Il demande à la base de refuser un stage_id qui ne correspond à aucune ligne de la table stages.

Que fait cascadeOnDelete() ?

Quand une scène est supprimée, la base supprime aussi tous les concerts dont le stage_id la désigne.

$stage->concerts ou $stage->concerts() ?

Sans parenthèses, vous recevez une collection de concerts. Avec parenthèses, vous recevez la relation, à compléter avec orderBy(), count() ou get().

Comment belongsTo(Stage::class) trouve-t-il la colonne stage_id ?

Il prend le nom de la méthode, stage, et ajoute _id. Une méthode scene() chercherait scene_id.

Que fait 'stage_id' => Stage::factory() dans une fabrique ?

Il crée une nouvelle scène pour chaque concert fabriqué, sauf si le tableau passé à create() donne déjà un stage_id.