Aller au contenu

Modèles : une table

Bloc Laravel · séance 4 · ~50 min

Slides du chapitre (PDF)PDF

Au chapitre Views, les trois concerts des Nuits du Parc sont écrits dans le tableau $concerts de ConcertController. Vous les rangez maintenant dans une table concerts de la base SQLite, lue par une classe Concert. À la fin, les pages /concerts et /concerts/{id} gardent leur mise en page, sans la liste des invités, mais leurs données viennent de la base.

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

  1. écrire une migration qui crée une table, puis l'appliquer et l'annuler avec Artisan,
  2. créer un modèle et déclarer avec #[Fillable] les colonnes que create() peut remplir,
  3. remplir la base avec un seeder, et la remettre à neuf avec migrate:fresh --seed,
  4. interroger une table avec all, find, findOrFail, where, orderBy, first et count, et inspecter le résultat avec dump() et dd(),
  5. afficher une page à partir de la base, puis modifier et supprimer une ligne.

1. Le tableau qui ne tient plus

Voici le début de la propriété écrite au chapitre Views :

private array $concerts = [
    1 => [
        'artist' => 'Duo Mistral',
        'stage' => 'Chapiteau',
        'time' => '18 h',
        …
    ],
    …
];

Chaque concert occupe huit lignes de la classe. Avec trente concerts, la propriété compterait environ deux cent quarante lignes avant la première méthode. Une autre page ne peut pas lire cette propriété privée. Pour annoncer le prochain concert sur l'accueil, il faudrait donc recopier le tableau dans un second contrôleur.

Le code qui sent mauvais

En binôme, cinq minutes. L'organisatrice du festival ne programme pas en PHP. Elle doit avancer le concert de Duo Mistral à 17 h. Qui peut faire cette modification, dans quel fichier, et que devient le site si la personne qui la fait efface une virgule par erreur ?

2. La migration

Au chapitre MVC, une migration a créé la table posts des articles. Créez maintenant celle des concerts, sans modèle. Artisan reconnaît la forme create_…_table de l'argument et prépare la création de la table concerts.

php artisan make:migration create_concerts_table
   INFO  Migration [database/migrations/2026_09_28_213249_create_concerts_table.php] created successfully.

Le nom du fichier commence par la date et l'heure de la commande, différentes chez vous. Sous Windows, Artisan affiche le chemin complet du fichier. Laravel exécute les migrations dans l'ordre de leurs noms. Voici le fichier créé :

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('concerts', function (Blueprint $table) {
            $table->id();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('concerts');
    }
};

return new class extends Migration crée l'objet d'une classe anonyme, lue au chapitre 11 de la POO. Vous connaissez le reste depuis le chapitre MVC : up() crée la table, down() la supprime, id() ajoute la clé primaire et timestamps() les colonnes created_at et updated_at. Dans up(), la fonction reçoit $table, un objet Blueprint, et chaque appel sur $table ajoute une colonne.

Ajoutez entre id() et timestamps() quatre des cinq colonnes d'un concert, toutes sauf la description :

Schema::create('concerts', function (Blueprint $table) {
    $table->id();
    $table->string('artist');
    $table->string('stage');
    $table->dateTime('starts_at');
    $table->unsignedSmallInteger('duration');
    $table->timestamps();
});

La méthode donne le type de la colonne, et son argument le nom de la colonne. starts_at contiendra la date et l'heure sous la forme 2026-10-16 18:00:00, que la base sait trier, et non plus le texte « 18 h ». duration compte des minutes. Voici les types utiles dans ce cours :

Méthode La colonne contient Exemple
string('artist') un texte court, 255 caractères au plus un nom
text('description') un texte long une description
unsignedSmallInteger('duration') un entier de 0 à 65 535 des minutes
date('released_on') une date sans heure une sortie de film
dateTime('starts_at') une date et une heure un début de concert
decimal('price', 5, 2) un nombre exact, 5 chiffres dont 2 décimales un prix

Les limites du tableau sont celles de MySQL, qui refuse une valeur trop longue ou hors plage. PostgreSQL fait respecter la longueur des textes et les décimales. SQLite, la base de votre projet, n'impose ni longueur ni plage : artist accepterait un nom de 300 caractères, et duration la valeur 70 000.

Une colonne déclarée ainsi refuse une case vide, null en PHP. Pour l'accepter, ajoutez ->nullable() après le type : $table->text('synopsis')->nullable().

Appliquer la migration

migrate exécute la méthode up() de chaque migration pas encore appliquée :

php artisan migrate
   INFO  Running migrations.

  2026_09_28_213249_create_concerts_table ........................ 7.72ms DONE

migrate:status affiche l'état de chaque migration :

php artisan migrate:status
  Migration name .............................................. Batch / Status
  0001_01_01_000000_create_users_table ............................... [1] Ran
  0001_01_01_000001_create_cache_table ............................... [1] Ran
  0001_01_01_000002_create_jobs_table ................................ [1] Ran
  2026_09_28_211118_create_posts_table ............................... [2] Ran
  2026_09_28_213249_create_concerts_table ............................ [3] Ran

Laravel inscrit chaque migration appliquée dans la table migrations de la base. Avant d'exécuter une migration, migrate cherche son nom dans cette table. Le numéro entre crochets est le lot : les migrations lancées par la même commande forment un lot. Relancez la commande :

php artisan migrate
   INFO  Nothing to migrate.

db:table décrit une table :

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

  Column ................................................................ Type
  id integer, autoincrement .......................................... integer
  artist varchar ..................................................... varchar
  stage varchar ...................................................... varchar
  starts_at datetime ................................................ datetime
  duration integer ................................................... integer
  created_at datetime, nullable ..................................... datetime
  updated_at datetime, nullable ..................................... datetime

  Index ......................................................................
  primary id ......................................................... primary

SQLite nomme varchar un texte court, sans longueur, et range unsignedSmallInteger parmi les integer. Dans VS Code, l'extension SQLite Viewer du chapitre Environnement de développement ouvre database/database.sqlite et montre la même table, encore vide.

Corriger une migration en cours de travail

Il manque la description. Or migrate ne rejoue pas une migration inscrite dans migrations. Annulez d'abord le dernier lot :

php artisan migrate:rollback
   INFO  Rolling back migrations.

  2026_09_28_213249_create_concerts_table ....................... 10.37ms DONE

Laravel a exécuté la méthode down() de la migration du lot 3, qui a supprimé la table concerts, puis il a effacé cette migration de la table migrations. Ajoutez la colonne sous duration :

$table->unsignedSmallInteger('duration');
$table->text('description');

Relancez la migration, puis décrivez de nouveau la table (sortie raccourcie) :

php artisan migrate
php artisan db:table concerts
   INFO  Running migrations.

  2026_09_28_213249_create_concerts_table ........................ 7.43ms DONE

  main.concerts ..............................................................
  Columns .................................................................. 8
…
  duration integer ................................................... integer
  description text ...................................................... text
  created_at datetime, nullable ..................................... datetime
…

Chaque ligne de la méthode up() devient une colonne de la table concerts

Ce retour en arrière ne vaut que sur votre machine. Chez un collègue ou sur un serveur, la migration déjà appliquée reste inscrite dans migrations, et migrate ne la rejoue pas. Une migration appliquée par d'autres ne se modifie plus : écrivez une nouvelle migration qui change la table.

3. Le modèle

Un modèle est une classe liée à une table : la classe interroge la table, et chaque objet de la classe représente une ligne. Créez celui des concerts :

php artisan make:model Concert
   INFO  Model [app/Models/Concert.php] created successfully.
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Concert extends Model
{
    //
}

Comme Post au chapitre MVC, la classe est vide. Par héritage, elle reçoit de Model les méthodes qui lisent et écrivent dans la base.

Vous n'avez écrit nulle part le nom de la table. Eloquent, la partie de Laravel qui relie les classes aux tables, le déduit du nom de la classe, mis au pluriel anglais et en minuscules, avec un _ entre les mots :

Classe Table
Concert concerts
ParkingRate parking_rates
Person people
Cheval chevals

Avec la classe Cheval, Eloquent interroge la table chevals. Une migration qui crée la table chevaux laisse donc le modèle chercher une table qui n'existe pas. C'est pourquoi tout le code du cours porte des noms anglais.

Un premier essai dans une route

Pour essayer le modèle sans écrire de page, vous utilisez une route bac à sable, une route réservée à vos essais. Dans routes/web.php, importez le modèle au-dessus de use Illuminate\Support\Facades\Route; :

use App\Models\Concert;

Ajoutez cette route à la fin du fichier. La méthode create() reçoit un tableau dont chaque clé porte le nom d'une colonne. Elle crée un objet Concert avec ces valeurs et l'enregistre dans la table.

Route::get('/bac-a-sable', function () {
    Concert::create([
        'artist' => 'Duo Mistral',
        'stage' => 'Chapiteau',
        'starts_at' => '2026-10-16 18:00:00',
        'duration' => 45,
        'description' => 'Guitare et violon, <strong>entrée libre</strong>.',
    ]);

    return 'Concert enregistré';
});

Ouvrez http://premier-test-laravel.test/bac-a-sable : Laravel répond par une erreur 500.

Illuminate\Database\Eloquent\MassAssignmentException
Add [artist] to fillable property to allow mass assignment on [App\Models\Concert].

Remplir plusieurs colonnes d'un coup avec un tableau s'appelle une affectation de masse. Laravel la refuse tant que le modèle ne donne pas la liste des colonnes qu'un tableau peut remplir. Le message cite artist, la première clé du tableau.

Écrivez la liste au-dessus de la classe Concert, avec l'attribut #[Fillable] lu dans User.php au chapitre Structure :

<?php

namespace App\Models;

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

#[Fillable(['artist', 'stage', 'starts_at', 'duration', 'description'])]
class Concert extends Model
{
    //
}

Rechargez la page, ou demandez-la avec curl :

curl -s http://premier-test-laravel.test/bac-a-sable
Concert enregistré

Avec la liste, create() ignore les clés absentes. Sans cette protection, un contrôleur d'inscription qui passerait à create() tout un formulaire laisserait un visiteur ajouter un champ is_admin et se rendre administrateur. Au chapitre MVC, forceCreate() ne consultait pas la liste.

Attention

Une colonne oubliée dans la liste est ignorée sans message. Sans 'description' dans #[Fillable], la base refuse la ligne incomplète : NOT NULL constraint failed: concerts.description. Devant ce message, relisez d'abord la liste.

Chaque visite ajoute un concert : rechargez la page, et la table contient deux lignes « Duo Mistral ».

4. Le seeder

Un seeder, vu au chapitre Structure, est une classe qui ajoute des données de départ dans la base.

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

Remplissez sa méthode run(), vide, avec les trois concerts du chapitre Views, le vendredi 16 octobre 2026, sans les invités :

<?php

namespace Database\Seeders;

use App\Models\Concert;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;

class ConcertSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        Concert::create([
            'artist' => 'Duo Mistral',
            'stage' => 'Chapiteau',
            'starts_at' => '2026-10-16 18:00:00',
            'duration' => 45,
            'description' => 'Guitare et violon, <strong>entrée libre</strong>.',
        ]);

        Concert::create([
            'artist' => "L'Orchestre du Lac",
            'stage' => 'Grande scène',
            'starts_at' => '2026-10-16 20:00:00',
            'duration' => 90,
            'description' => 'Un orchestre de trente musiciens, <strong>gratuit</strong> pour les moins de 12 ans.',
        ]);

        Concert::create([
            'artist' => 'Orage Mécanique',
            'stage' => 'Grande scène',
            'starts_at' => '2026-10-16 22:00:00',
            'duration' => 75,
            'description' => 'Rock et percussions pour la <strong>clôture</strong>.',
        ]);
    }
}

La commande db:seed exécute DatabaseSeeder, créé avec le projet. Sa méthode run() crée déjà un utilisateur de test, test@example.com. Ajoutez à la fin l'appel de votre seeder :

public function run(): void
{
    // User::factory(10)->create();

    User::factory()->create([
        'name' => 'Test User',
        'email' => 'test@example.com',
    ]);

    $this->call(ConcertSeeder::class);
}
php artisan db:seed
   INFO  Seeding database.

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

La table concerts contient cinq lignes : les deux « Duo Mistral » de la route bac à sable, puis les trois du seeder. Relancez la commande :

php artisan db:seed
   INFO  Seeding database.


   Illuminate\Database\UniqueConstraintViolationException

  SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: users.email (Connection: sqlite, …

DatabaseSeeder a voulu créer une seconde fois l'utilisateur test@example.com, et la table users interdit deux lignes avec la même adresse. Un seeder ajoute des lignes à celles qui existent. Pour tout reprendre à zéro, lancez migrate:fresh avec l'option --seed (sortie raccourcie) :

php artisan migrate:fresh --seed
  Dropping all tables ............................................ 4.77ms DONE

   INFO  Preparing database.
…
   INFO  Running migrations.

  0001_01_01_000000_create_users_table .......................... 23.02ms DONE
…
  2026_09_28_213249_create_concerts_table ........................ 3.76ms DONE


   INFO  Seeding database.

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

La commande supprime toutes les tables, sans passer par les méthodes down(). Elle applique ensuite toutes les migrations, puis lance DatabaseSeeder. La base contient un utilisateur et les trois concerts, numérotés 1, 2 et 3.

Attention

migrate:fresh a aussi vidé la table posts : les trois articles du chapitre MVC ne figuraient dans aucun seeder, et /posts affiche maintenant une liste vide. Ne lancez jamais cette commande sur une base dont vous gardez les données, comme celle d'un site en ligne.

5. Lire la base sans page

Deux fonctions montrent ce que renvoie une requête. dump() affiche dans la page le contenu d'une variable, puis le code continue. dd() affiche le contenu, puis arrête le programme : Laravel n'envoie aucune vue.

Concert::all() demande à la base toutes les lignes de la table concerts. Remplacez le contenu de la route bac à sable :

Route::get('/bac-a-sable', function () {
    dd(Concert::all());
});

Ouvrez /bac-a-sable. La page affiche un bloc sombre :

Illuminate\Database\Eloquent\Collection {#1522 ▼ // routes/web.php:182
  #items: array:3 [▶]
  #escapeWhenCastingToString: false
}

La première ligne donne la classe du résultat, une collection comme au chapitre MVC, puis le fichier et la ligne du dd(). Le numéro après # peut différer chez vous. Avec ▶, dépliez #items, puis le premier élément, puis #attributes (sortie raccourcie) :

  #items: array:3 [▼
    0 => App\Models\Concert {#1534 ▼
      #connection: "sqlite"
      #table: "concerts"
…
      #attributes: array:8 [▼
        "id" => 1
        "artist" => "Duo Mistral"
        "stage" => "Chapiteau"
        "starts_at" => "2026-10-16 18:00:00"
        "duration" => 45
        "description" => "Guitare et violon, <strong>entrée libre</strong>."
        "created_at" => "2026-09-28 21:35:32"
        "updated_at" => "2026-09-28 21:35:32"
      ]
…

Chaque élément est un objet Concert, qui représente une ligne de la table concerts. #attributes contient une valeur par colonne. Eloquent a rempli created_at et updated_at lui-même, pendant create(). Vous lisez ces valeurs avec une flèche, comme $post->title au chapitre MVC.

Un objet, null ou un nombre

Plusieurs dump() peuvent précéder le dd() final :

Route::get('/bac-a-sable', function () {
    dump(Concert::count());
    dump(Concert::find(2)->artist);
    dd(Concert::find(99));
});
3 // routes/web.php:182

"L'Orchestre du Lac" // routes/web.php:183

null // routes/web.php:184

find(2) cherche la ligne dont l'id vaut 2 et renvoie un seul objet Concert, pas une collection. find(99) ne trouve aucune ligne et renvoie null.

Dans une vue, $concert->artist sur null arrêterait la page avec une erreur 500. findOrFail() cherche de la même façon, mais lance une exception quand la ligne n'existe pas, et Laravel la transforme en réponse 404. Remplacez le contenu de la route par dd(Concert::findOrFail(99)); :

curl -s -i http://premier-test-laravel.test/bac-a-sable
HTTP/1.1 404 Not Found
…

Le dd() n'affiche rien : l'exception a arrêté le traitement avant lui.

Filtrer et trier

where() ajoute une condition à la requête, et orderBy() un tri, sans encore l'envoyer à la base. get(), first() ou count() l'envoient. Une collection se parcourt avec foreach, comme un tableau :

Route::get('/bac-a-sable', function () {
    foreach (Concert::where('stage', 'Grande scène')->get() as $concert) {
        dump($concert->artist);
    }

    dump(Concert::orderBy('starts_at', 'desc')->first()->artist);
    dd(Concert::where('stage', 'Grande scène')->orderBy('starts_at')->toRawSql());
});
"L'Orchestre du Lac" // routes/web.php:183

"Orage Mécanique" // routes/web.php:183

"Orage Mécanique" // routes/web.php:186

"select * from "concerts" where "stage" = 'Grande scène' order by "starts_at" asc" // routes/web.php:187

orderBy('starts_at', 'desc') trie de l'heure la plus tardive à la plus tôt, et first() prend donc le dernier concert de la soirée. Sans second argument, le tri est croissant, asc. toRawSql(), vu au chapitre MVC, montre le SQL de la requête.

Vous écrivez Vous recevez
Concert::all(), …->get() une collection d'objets Concert, peut-être vide
Concert::find(2), …->first() un objet Concert, ou null
Concert::findOrFail(2) un objet Concert, ou une réponse 404
Concert::count(), …->count() un entier

Ce que renvoient get(), find(), first() et count() sur la table des trois concerts

Écrivez findOrFail() dans un contrôleur quand l'id vient de l'adresse, find() quand null vous convient, et where() pour chercher selon une autre colonne.

6. La page lit la base

Remplacez tout le contenu de ConcertController.php. La propriété $concerts disparaît : index() lit les concerts triés par heure, et show() lit celui dont l'id est dans l'adresse.

<?php

namespace App\Http\Controllers;

use App\Models\Concert;
use Illuminate\View\View;

class ConcertController extends Controller
{
    public function index(): View
    {
        $concerts = Concert::orderBy('starts_at')->get();

        return view('concerts.index', ['concerts' => $concerts]);
    }

    public function show(int $id): View
    {
        $concert = Concert::findOrFail($id);

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

Au chapitre Views, show() testait la clé avec isset() et appelait abort(404). findOrFail() remplace ce test. Sans toucher aux vues, demandez le programme :

curl -s http://premier-test-laravel.test/concerts
…
            <article class="concert-card">
    <h2>Duo Mistral</h2>
    <p>Chapiteau, </p>
    </article>
        <a href="http://premier-test-laravel.test/concerts/0">Voir la fiche</a>
…

Les noms s'affichent encore, car un modèle accepte aussi l'écriture $concert['artist']. L'heure reste vide, car la table n'a pas de colonne time. Le lien vise /concerts/0, car $id reçoit maintenant la position du concert dans la collection, qui commence à 0. Enfin, la fiche /concerts/2 répond 500, car $concert['guests'] vaut null :

foreach() argument must be of type array|object, null given (View: …/resources/views/concerts/show.blade.php)

Les vues lisent un objet

Un concert est maintenant un objet, comme $post au chapitre MVC. Dans les trois vues, $concert['artist'] devient $concert->artist, et de même pour les autres clés. Remplacez le contenu de components/concert-card.blade.php :

@props(['concert'])

<article class="concert-card">
    <h2>{{ $concert->artist }}</h2>
    <p>{{ $concert->stage }}, {{ $concert->starts_at->format('G \h') }}</p>
    @isset($note)
        <p class="note">{{ $note }}</p>
    @endisset
</article>

Dans concerts/index.blade.php, la boucle ne lit plus de clé, et le lien utilise la colonne id :

<x-layouts.app title="Programme">
    <h1>Programme</h1>
    @foreach ($concerts as $concert)
        <x-concert-card :concert="$concert">
            @if ($concert->duration >= 90)
                <x-slot:note>Entracte de 15 minutes</x-slot:note>
            @endif
        </x-concert-card>
        <a href="{{ route('concerts.show', $concert->id) }}">Voir la fiche</a>
    @endforeach
</x-layouts.app>

Dans concerts/show.blade.php, faites les mêmes remplacements, et supprimez le titre « Invités » avec sa liste :

<x-layouts.app>
    <x-slot:title>Concert de {{ $concert->artist }}</x-slot:title>

    {{-- Ce commentaire Blade n'arrive pas dans la page --}}
    <!-- Ce commentaire HTML arrive dans la page -->
    <h1>{{ $concert->artist }}</h1>
    <p>{{ $concert->stage }}, {{ $concert->starts_at->format('G \h') }}</p>

    @if ($concert->duration >= 90)
        <p>Long concert : {{ $concert->duration }} minutes, avec entracte.</p>
    @elseif ($concert->duration >= 60)
        <p>Concert de {{ $concert->duration }} minutes, sans entracte.</p>
    @else
        <p>Concert court : {{ $concert->duration }} minutes.</p>
    @endif

    <p>{{ $concert->description }}</p>
    <p>{!! $concert->description !!}</p>
</x-layouts.app>

Rechargez le programme :

Call to a member function format() on string (View: …/resources/views/components/concert-card.blade.php) (View: …/resources/views/components/concert-card.blade.php)

Eloquent donne à chaque colonne la valeur que la base lui envoie. starts_at arrive donc sous forme de texte, "2026-10-16 18:00:00", et une chaîne n'a pas de méthode format(). Une conversion, cast en anglais, transforme la valeur d'une colonne à chaque lecture. Déclarez-la dans la méthode casts() de Concert :

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

Avec la conversion datetime, $concert->starts_at renvoie un objet Carbon, la bibliothèque de dates installée au chapitre Framework et librairie.

La méthode format() de Carbon écrit la date selon des lettres. G donne l'heure sans zéro devant, et \h écrit la lettre h telle quelle. format('G \h') renvoie donc 18 h, comme l'ancienne clé time.

curl -s http://premier-test-laravel.test/concerts
…
    <main>
        <h1>Programme</h1>
            <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>L&#039;Orchestre du Lac</h2>
    <p>Grande scène, 20 h</p>
            <p class="note">Entracte de 15 minutes</p>
    </article>
        <a href="http://premier-test-laravel.test/concerts/2">Voir la fiche</a>
…
curl -s http://premier-test-laravel.test/concerts/2
…
    <main>
        <!-- Ce commentaire HTML arrive dans la page -->
    <h1>L&#039;Orchestre du Lac</h1>
    <p>Grande scène, 20 h</p>

            <p>Long concert : 90 minutes, avec entracte.</p>
…
    <p>Un orchestre de trente musiciens, <strong>gratuit</strong> pour les moins de 12 ans.</p>
    </main>
…

Le HTML est celui du chapitre Views, sans la liste des invités, et /concerts/9 répond toujours 404. Les routes n'ont pas changé :

php artisan route:list --path=concerts
  GET|HEAD       concerts ............................... concerts.index › ConcertController@index
  GET|HEAD       concerts/{id} ............................ concerts.show › ConcertController@show

                                                                                Showing [2] routes

7. Modifier et supprimer

Pour modifier un concert, lisez-le, changez une propriété, puis appelez save() :

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

    dd($concert);
});

Dépliez #attributes (sortie raccourcie) :

  #attributes: array:8 [▼
    "id" => 1
…
    "duration" => 60
…
    "created_at" => "2026-09-28 21:35:32"
    "updated_at" => "2026-09-28 21:38:29"
  ]

save() envoie à la base une requête UPDATE avec la nouvelle durée. Eloquent y ajoute updated_at, mis à l'heure de l'enregistrement, et created_at garde l'heure de la création. La fiche /concerts/1 affiche maintenant Concert de 60 minutes, sans entracte. Au chapitre Todo List, un formulaire appellera ce save() : l'organisatrice changera une heure sans toucher au PHP.

La méthode update() modifie plusieurs colonnes avec un tableau, comme create(), et passe donc elle aussi par #[Fillable]. La méthode delete() supprime la ligne de l'objet :

Route::get('/bac-a-sable', function () {
    $concert = Concert::find(1);
    $concert->update(['stage' => 'Kiosque', 'duration' => 30]);

    Concert::find(3)->delete();

    dd(Concert::count());
});
2 // routes/web.php:187

Le programme n'affiche plus que deux concerts, et /concerts/3 répond 404. Rechargez la route bac à sable :

Call to a member function delete() on null

Le concert 3 n'existe plus : find(3) a renvoyé null, qui n'a pas de méthode delete(). Remettez les trois concerts d'origine :

php artisan migrate:fresh --seed

La sortie est celle de la section 4. Supprimez enfin la route /bac-a-sable et l'import de Concert dans routes/web.php. Cette adresse modifie la base à chaque visite, et un site ne garde pas une telle route.

À retenir

  • Une migration décrit une table avec up() et down(), et migrate n'applique que celles que la table migrations ne contient pas encore.
  • Un modèle comme Concert lit la table concerts, dont Eloquent déduit le nom avec les règles du pluriel anglais.
  • create() et update() ne remplissent que les colonnes que #[Fillable] déclare.
  • get() et all() renvoient une collection, find() et first() un objet ou null, et findOrFail() répond 404 à la place de null.
  • migrate:fresh --seed supprime toutes les tables, rejoue les migrations et relance les seeders, et seules les données d'un seeder reviennent.

Exercices

Comptez 85 minutes pour les exercices, en séance ou à la maison, et 10 minutes de plus la première fois pour installer le dépôt.

Les katas 10.1 à 10.6 se font dans le dépôt eloquent-26, un projet Laravel 13 qui ne contient que des tests, sur le cinéma de l'exercice 1 du chapitre Controllers.

Installer le dépôt (10 minutes, une seule fois)

Sur github.com/opmvpc/eloquent-26, créez votre copie avec le bouton Fork. Ouvrez ensuite un terminal dans le dossier de Herd ou de Laragon, celui qui contient premier-test-laravel, et clonez votre copie :

git clone https://github.com/VOTRE-COMPTE/eloquent-26.git
cd eloquent-26

Le dépôt ne contient ni vendor/, ni .env, ni database.sqlite : ces fichiers sont exclus de Git, comme vous l'avez vu au chapitre Installation. Chaque projet cloné se prépare avec les mêmes étapes, que laravel new avait lancées pour vous :

composer install
cp .env.example .env
php artisan key:generate
php artisan migrate
  • composer install recrée vendor/ à partir de composer.lock. Sans ce dossier, php artisan s'arrête sur Failed opening required '…/vendor/autoload.php'.
  • cp .env.example .env copie le modèle de configuration versionné vers votre .env, qui reste hors Git. La commande fonctionne dans PowerShell comme dans le Terminal de macOS.
  • php artisan key:generate remplit la ligne APP_KEY= de .env. Sans clé, chaque page répond 500 avec MissingAppKeyException.
  • php artisan migrate remarque que la base n'existe pas encore et propose de la créer :
   WARN  The SQLite database configured for this application does not exist: …/eloquent-26/database/database.sqlite.

 Would you like to create it? (yes/no) [yes]
❯
   INFO  Preparing database.

  Creating migration table ............................................. 12.38ms DONE

   INFO  Running migrations.

  0001_01_01_000000_create_users_table ................................. 24.88ms DONE
  0001_01_01_000001_create_cache_table ................................. 18.41ms DONE
  0001_01_01_000002_create_jobs_table .................................. 27.93ms DONE

Appuyez sur Entrée pour répondre yes. Laravel crée database/database.sqlite et y applique les trois migrations livrées avec le projet. Les tables du cinéma viendront de vos migrations. Avec Herd, le site répond aussitôt à l'adresse http://eloquent-26.test. Avec Laragon, rechargez-le depuis son menu pour qu'il découvre le nouveau dossier.

Lancer les tests

Les tests du chapitre forment le groupe chapitre10 :

php artisan test --group=chapitre10

Au départ, les 18 tests sont rouges. Ils tournent sur leur propre base, en mémoire, et ne touchent pas à database.sqlite. Pour voir vos pages à l'adresse http://eloquent-26.test/films, lancez php artisan migrate:fresh --seed. Le README du dépôt explique comment lire un test rouge et rappelle les noms que les tests imposent.

10.1 La table des films (10 minutes)

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

  • title, un texte court,
  • duration, une durée en minutes,
  • released_on, une date sans heure,
  • synopsis, un texte long qui peut rester vide.

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

php artisan make:migration create_movies_table
Test Ce que vous écrivez
la table movies existe avec les colonnes title, duration, released_on, synopsis et les timestamps les quatre colonnes dans up()
duration est une colonne d'entiers et released_on une colonne de dates unsignedSmallInteger() et date()
un film peut être enregistré sans synopsis (synopsis accepte null) text('synopsis')->nullable()

10.2 Le modèle (10 minutes)

Créez le modèle Movie et déclarez ses quatre colonnes avec #[Fillable]. Inspirez-vous de la section 3.

php artisan make:model Movie
Test Ce que vous écrivez
Movie::create() écrit une ligne dans la table movies #[Fillable([...])] au-dessus de la classe
un film créé avec Movie::create() se relit avec ses valeurs les quatre noms de colonnes, sans faute
sans synopsis, Movie::create() laisse synopsis à null rien de plus

10.3 Les cinq films (15 minutes)

Créez MovieSeeder avec ces cinq films, et appelez-le depuis DatabaseSeeder. Pour « Les Jours sans », n'écrivez pas la clé synopsis. Inspirez-vous de la section 4.

php artisan make:seeder MovieSeeder
title duration released_on synopsis
Terminus Nord 112 2024-02-14 Une contrôleuse de train découvre une lettre oubliée dans un wagon vide.
La Nuit des Lucioles 98 2024-09-04 Trois amis d'enfance se retrouvent pour une dernière nuit au bord du lac.
Marée basse 134 2019-11-20 Sur la côte, un gardien de phare refuse de partir à la retraite.
Les Jours sans 87 2011-03-09 aucun
Sous le pont 105 2003-06-25 Un vieux musicien apprend le violon à une fillette du quartier.
Test Ce que vous écrivez
MovieSeeder crée exactement les cinq films de l'énoncé cinq Movie::create([...]) dans run()
MovieSeeder enregistre les durées, les dates de sortie et les synopsis de l'énoncé les valeurs du tableau
DatabaseSeeder appelle MovieSeeder : après db:seed, les cinq films sont en base $this->call(MovieSeeder::class);

10.4 La liste (15 minutes)

Créez MovieController avec une méthode index(), qui passe les films triés par titre à la vue movies.index. La vue les affiche dans un <table>, une ligne <tr> par film. Reliez index() à GET /films, route nommée movies.index. Inspirez-vous de la section 6.

Le dépôt ne contient aucun layout. Écrivez dans la vue une page HTML complète, ou créez d'abord votre layout comme au chapitre Views, avec php artisan make:component layouts.app --view.

php artisan make:controller MovieController
php artisan make:view movies.index
Test Ce que vous écrivez
la page /films répond 200 et affiche les cinq titres dans l'ordre alphabétique Movie::orderBy('title')->get() et un @foreach
la page /films présente les films dans un tableau HTML <table>, puis <tr> et <td> dans la boucle
la route /films s'appelle movies.index ->name('movies.index')

10.5 La fiche (15 minutes)

Ajoutez show(int $id) et la route GET /films/{id}, avec whereNumber('id'), nommée movies.show. La vue movies.show affiche le titre, la durée en heures et minutes, et le synopsis.

php artisan make:view movies.show

La fonction PHP intdiv() donne le quotient d'une division entière, et % son reste :

<p>{{ intdiv($movie->duration, 60) }} h {{ $movie->duration % 60 }}</p>
Test Ce que vous écrivez
la fiche d'un film affiche son titre, sa durée en « 1 h 52 » et son synopsis Movie::findOrFail($id) et la vue
la durée d'un autre film est aussi écrite en heures et minutes : 98 minutes donnent « 1 h 38 » la ligne ci-dessus
un numéro de film qui n'existe pas répond 404 (findOrFail) findOrFail(), pas find()
la route /films/{id} s'appelle movies.show ->name('movies.show')

10.6 Le filtre par année (10 minutes)

/films?annee=2024 n'affiche que les films sortis en 2024, et « Aucun film » si aucun ne correspond. Sans ?annee=, la liste reste complète. Dans index(Request $request), lisez l'année avec $request->query('annee'), comme au chapitre Controllers, et importez Illuminate\Http\Request. whereYear() fonctionne comme where(), mais ne compare que l'année d'une date. Complétez la requête avant get() :

$year = $request->query('annee');
$query = Movie::orderBy('title');

if ($year !== null) {
    $query->whereYear('released_on', $year);
}
Test Ce que vous écrivez
/films?annee=2024 ne garde que les deux films sortis en 2024 les lignes ci-dessus, puis $query->get()
/films?annee=1999 n'affiche aucun titre, seulement le message « Aucun film » @forelse et @empty dans la vue

10.7 Prédire cinq requêtes (sur papier, 10 minutes)

La table concerts contient les trois concerts du seeder. Écrivez ce que renvoie chaque ligne : un nombre, un texte, null, un objet ou une collection, avec son contenu.

Concert::count();
Concert::find(3)->stage;
Concert::where('stage', 'Grande scène')->get();
Concert::orderBy('duration')->first()->artist;
Concert::find(4);

10.8 Six cartes

Que fait php artisan migrate ?

Il exécute la méthode up() de chaque migration absente de la table migrations, puis il l'y inscrit.

Quelle table lit le modèle ParkingRate ?

parking_rates : Eloquent met le nom de la classe au pluriel anglais, en minuscules, avec un _ entre les mots.

Pourquoi écrire #[Fillable] ?

Pour qu'un visiteur ne puisse pas remplir une colonne que le formulaire ne prévoit pas. create() et update() ignorent les clés absentes de la liste. Sans liste, ils lèvent une MassAssignmentException.

find() ou findOrFail() dans un contrôleur ?

findOrFail() : il répond 404 quand la ligne n'existe pas. find() renverrait null, et la vue s'arrêterait sur une erreur 500.

Que renvoient get() et first() ?

get() renvoie une collection d'objets, peut-être vide. first() renvoie un seul objet, ou null.

Que fait php artisan migrate:fresh --seed ?

Il supprime toutes les tables, rejoue toutes les migrations, puis lance DatabaseSeeder. Les lignes qui ne viennent pas d'un seeder sont perdues.