Aller au contenu

Modèles, migrations, fabriques, seeders : vos trois projets dans la base

Temps 1 · séance 2 · ~70 min

Les slides du chapitre (PDF)

  • Traduire le diagramme de classes en trois migrations, table pivot comprise.
  • Écrire les modèles Project et Tag et leur relation plusieurs à plusieurs.
  • Écrire une fabrique qui invente des projets pour les tests.
  • Enregistrer vos trois projets dans un seeder, avec les textes et les captures du devoir.
  • Vérifier le contenu de la base avec Tinker.

1. Du diagramme aux tables

À la séance 1, vous avez dessiné le diagramme de classes de votre portfolio. Voici celui du cours. Comparez-le au vôtre avant de continuer : un écart n'est pas une faute, mais chaque différence doit avoir une raison.

@startuml
class User {
  +name: string
  +email: string
  +password: string
}

class Project {
  +title: string
  +slug: string
  +summary: string
  +content: string [0..1]
  +cover_path: string [0..1]
  +demo_url: string [0..1]
  +repo_url: string [0..1]
  +published_at: datetime [0..1]
  +position: int
  +tags: Tag [0..*]
}

class Tag {
  +name: string
  +slug: string
  +projects: Project [0..*]
}

class ContactMessage {
  +name: string
  +email: string
  +message: string
}

Project "0..* +projects" -- "0..* +tags" Tag : porte >
@enduml

Le trait simple {uml:association} entre Project et Tag porte 0..* à chaque bout. Un projet porte zéro, une ou plusieurs étiquettes, et une étiquette se retrouve sur zéro, un ou plusieurs projets. Le verbe « porte » se lit de Project vers Tag, dans le sens de la petite flèche qui le suit. Chaque rôle est le nom de la propriété qui le porte, tags dans Project et projects dans Tag, et revient dans la boîte de sa classe. En base, cette relation plusieurs à plusieurs demande une table pivot, project_tag, qui relie les deux tables sans apparaître dans le diagramme.

User et ContactMessage ne sont reliés à rien. Le site n'a qu'un auteur, donc chaque projet est le sien, et aucune propriété ne doit le rappeler. Un message vient d'un visiteur qui n'a pas de compte, si bien qu'aucun User ne l'a écrit. Le kit donne à User d'autres colonnes utiles à la connexion, que le diagramme laisse de côté.

Le slug est le titre écrit pour une adresse, en minuscules et avec des tirets : « Le refuge du Brabant wallon » devient le-refuge-du-brabant-wallon. content contient le texte riche, en HTML, et reste vide tant que le texte n'est pas écrit. published_at reste vide tant que le projet est un brouillon, et position donne l'ordre d'affichage. La colonne existe pour tous, car la liste du chapitre 1.07 trie les projets par position. Les boutons monter et descendre du chapitre 1.16 restent un bonus.

Le diagramme devient maintenant des tables. Vous connaissez le chemin depuis le festival de 5XCOS : une classe donne une table, un attribut donne une colonne, et une relation plusieurs à plusieurs demande une table pivot.

Dans le diagramme Dans la base
la classe Project la table projects, au pluriel anglais
+title: string $table->string('title')
un attribut marqué [0..1] une colonne ->nullable(), qui accepte l'absence de valeur
0..* aux deux bouts du trait entre Project et Tag la table pivot project_tag
rien id, created_at et updated_at, ajoutées par Laravel

Deux classes du diagramme ne demandent rien aujourd'hui. La table users existe déjà, car le kit l'a créée avec ses colonnes de connexion : ne la recréez pas. ContactMessage attend le formulaire de contact du chapitre 1.20.

2. Trois commandes pour commencer

Une seule commande crée le modèle, sa migration et sa fabrique.

php artisan make:model Project -mf
   INFO  Model [app/Models/Project.php] created successfully.
   INFO  Factory [database/factories/ProjectFactory.php] created successfully.
   INFO  Migration [database/migrations/2026_10_25_122829_create_projects_table.php] created successfully.

Vos noms de fichiers porteront la date et l'heure du jour.

L'option -m ajoute la migration, et -f la fabrique. Le nom du fichier de migration commence par la date et l'heure de sa création, à la seconde près. Ce préfixe fixe l'ordre dans lequel Laravel lance les migrations.

Faites de même pour les étiquettes, puis créez la migration de la table pivot à part :

php artisan make:model Tag -mf
php artisan make:migration create_project_tag_table

L'ordre compte. La table pivot contient des clés étrangères vers projects et vers tags, et une clé étrangère ne peut pointer que vers une table qui existe déjà. Créée en dernier, la migration de project_tag porte l'horodatage le plus récent, et elle passe donc après les deux autres.

3. La migration des projets

Ouvrez la migration create_projects_table et remplissez le corps de up() :

Schema::create('projects', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->string('slug')->unique();
    $table->string('summary', 500);
    $table->longText('content')->nullable();
    $table->string('cover_path')->nullable();
    $table->string('demo_url')->nullable();
    $table->string('repo_url')->nullable();
    $table->timestamp('published_at')->nullable();
    $table->unsignedInteger('position')->default(0);
    $table->timestamps();
});

Chaque ligne reprend un attribut du diagramme. Les colonnes nullable sont celles qui peuvent manquer : un projet sans démo en ligne, un dépôt resté privé, une image pas encore choisie, un brouillon sans date de publication.

content est lui aussi nullable : au chapitre 1.12, vous créerez un projet depuis le back-office, et vous voudrez parfois l'enregistrer avant d'avoir écrit son texte. Si votre diagramme ne donne pas [0..1] à content, ajoutez-le, pour qu'il reste fidèle à la base.

Quatre détails de cette migration méritent une phrase.

  • ->unique() sur slug. Le slug sert d'adresse au projet, /projets/quiz-des-capitales. Deux projets ne peuvent pas partager la même adresse, et la base refuse le second.
  • string('summary', 500). Une colonne string accepte 255 caractères par défaut. Le second argument porte la limite à 500, une marge confortable pour un résumé d'une phrase.
  • longText accepte un texte beaucoup plus long que string. Le contenu sera du HTML, avec ses balises, et il peut devenir long.
  • ->default(0) sur position. Un projet créé sans position reçoit 0. La colonne servira à choisir l'ordre d'affichage.

La migration create_tags_table est plus courte. Entre id() et timestamps(), elle ajoute deux colonnes :

$table->string('name');
$table->string('slug')->unique();

Le slug d'une étiquette, comme vue-js ou laravel, servira au filtre des projets par étiquette si vous le choisissez en bonus.

4. La table pivot

Remplissez la migration create_project_tag_table :

Schema::create('project_tag', function (Blueprint $table) {
    $table->foreignId('project_id')->constrained()->cascadeOnDelete();
    $table->foreignId('tag_id')->constrained()->cascadeOnDelete();
    $table->primary(['project_id', 'tag_id']);
});

Vous retrouvez la table artist_concert du festival de 5XCOS. Le nom réunit les deux modèles au singulier, dans l'ordre alphabétique : project avant tag. Chaque ligne relie un projet à une étiquette.

  • foreignId('project_id')->constrained() crée la colonne et la clé étrangère vers projects.id. Laravel déduit le nom de la table à partir du nom de la colonne.
  • cascadeOnDelete() supprime les lignes de la pivot quand le projet ou l'étiquette disparaît. Les étiquettes elles-mêmes restent.
  • primary(['project_id', 'tag_id']) fait des deux colonnes la clé primaire. La même paire ne peut donc pas être enregistrée deux fois.

La table pivot n'a ni id ni timestamps(). Elle n'a pas de modèle non plus : les deux modèles la manipulent à travers leur relation.

5. Les modèles

Le modèle généré contient seulement use HasFactory; et son commentaire. Complétez app/Models/Project.php :

<?php

namespace App\Models;

use Database\Factories\ProjectFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

#[Fillable(['title', 'slug', 'summary', 'content', 'cover_path', 'demo_url', 'repo_url', 'published_at', 'position'])]
class Project extends Model
{
    /** @use HasFactory<ProjectFactory> */
    use HasFactory;

    protected function casts(): array
    {
        return ['published_at' => 'datetime'];
    }

    /** @return BelongsToMany<Tag, $this> */
    public function tags(): BelongsToMany
    {
        return $this->belongsToMany(Tag::class);
    }
}

Vous connaissez l'attribut #[Fillable] depuis 5XCOS. Il liste les colonnes que Project::create([...]) a le droit de remplir, et il remplace l'ancienne propriété protected $fillable qu'on trouve encore dans beaucoup de tutoriels. Le User du kit utilise la même écriture.

La méthode casts() demande à Laravel de convertir published_at. Quand vous lisez $project->published_at, vous recevez un objet date avec ses méthodes, et non une simple chaîne.

La méthode tags() déclare la relation plusieurs à plusieurs. belongsToMany(Tag::class) suffit, sans autre argument, parce que vous avez suivi les conventions de Laravel. Il en déduit la table project_tag et les colonnes project_id et tag_id. Les deux commentaires /** … */ servent à PHPStan, l'outil d'analyse que le kit a déjà configuré.

app/Models/Tag.php a la même forme, avec ses deux colonnes et la relation inverse :

<?php

namespace App\Models;

use Database\Factories\TagFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

#[Fillable(['name', 'slug'])]
class Tag extends Model
{
    /** @use HasFactory<TagFactory> */
    use HasFactory;

    /**
     * @return BelongsToMany<Project, $this>
     */
    public function projects(): BelongsToMany
    {
        return $this->belongsToMany(Project::class);
    }
}

Les deux méthodes correspondent aux deux rôles du diagramme : +tags dans Project, +projects dans Tag.

6. La fabrique

Une fabrique est une classe qui produit des objets d'un modèle avec des valeurs inventées. Vous l'avez vue en 5XCOS. Ici, elle ne remplit pas votre portfolio : elle servira aux tests du chapitre 1.17, qui ont besoin de projets jetables. Complétez database/factories/ProjectFactory.php :

public function definition(): array
{
    $title = fake()->unique()->sentence(3);

    return [
        'title' => $title,
        'slug' => Str::slug($title),
        'summary' => fake()->sentence(12),
        'content' => '<p>'.fake()->paragraph().'</p>',
        'demo_url' => fake()->url(),
        'repo_url' => 'https://github.com/'.fake()->userName().'/'.Str::slug($title),
        'published_at' => now(),
        'position' => 0,
    ];
}

public function draft(): static
{
    return $this->state(['published_at' => null]);
}

Ajoutez use Illuminate\Support\Str; en tête du fichier. fake()->unique() garantit que deux titres de la même série sont différents, donc deux slugs aussi. Str::slug() fabrique le slug à partir du titre, la transformation décrite à la section 1.

La méthode draft() est un état, une variante de la fabrique qui remplace certaines valeurs. Ici, elle vide published_at pour produire un brouillon. Elle servira quand vous distinguerez les projets publiés des brouillons, au chapitre 1.16.

Essayez la fabrique sans rien enregistrer. La méthode make() crée l'objet en mémoire, alors que create() l'enregistre dans la base :

php artisan tinker --execute="echo App\Models\Project::factory()->draft()->make()->toJson(JSON_PRETTY_PRINT);"
{
    "title": "Nihil dignissimos quos est.",
    "slug": "nihil-dignissimos-quos-est",
    "summary": "Facilis quos vel ex numquam corrupti voluptatem eaque rerum ut eum nisi iure omnis.",
    "content": "<p>Labore rerum magni praesentium rerum. Quasi tenetur corrupti deleniti officiis voluptatum. Nemo perspiciatis eos et praesentium repellendus tempore rem. Dolorem impedit aut ut quas. Ducimus nihil autem tenetur non.<\/p>",
    "demo_url": "http:\/\/oconner.com\/dolores-maiores-quia-omnis-quidem-nobis",
    "repo_url": "https:\/\/github.com\/casper.shirley\/nihil-dignissimos-quos-est",
    "published_at": null,
    "position": 0
}

Vos valeurs seront différentes, puisque fake() les tire au hasard. Le faux latin ne doit jamais arriver sur votre site public : il reste dans les tests.

TagFactory suit le même modèle, avec un mot au hasard pour name et son slug. Complétez database/factories/TagFactory.php, avec le même use Illuminate\Support\Str; :

public function definition(): array
{
    $name = fake()->unique()->word();

    return [
        'name' => $name,
        'slug' => Str::slug($name),
    ];
}

Sans unique(), deux étiquettes de la même série peuvent recevoir le même mot. La contrainte unique de la colonne slug refuse alors la seconde.

7. Le seeder : vos trois projets

Un seeder enregistre des lignes dans la base. Le vôtre enregistre vos vrais projets, ceux du devoir de la semaine, avec leurs textes et leurs captures. Créez-le :

php artisan make:seeder ProjectSeeder

Les captures

Vos captures ne sont pas du code : ce sont des fichiers que l'application sert, comme les images que le back-office téléversera au chapitre 1.14. Laravel les range sur un disque, un dossier déclaré dans config/filesystems.php. Le disque public est le dossier storage/app/public/. Créez-y un dossier images/ et copiez-y la capture d'ensemble de chaque projet. Donnez à chaque fichier un nom court tiré du titre, en minuscules, sans espace ni accent : quiz-capitales.jpg. Toutes vos captures ont la même largeur, comme le demandait la fiche projet.

La colonne cover_path reçoit le chemin de l'image à partir du disque, sans barre oblique au début : images/quiz-capitales.jpg. Au chapitre 1.14, les images téléversées depuis le back-office iront sur le même disque, dans un dossier projects/.

Le serveur web ne sert que le dossier public/ : le navigateur n'atteint pas storage/. Créez le lien qui relie les deux, une fois par projet :

php artisan storage:link
   INFO  The [public/storage] link has been connected to [storage/app/public].

public/storage renvoie vers storage/app/public, et http://portfolio.test/storage/images/quiz-capitales.jpg affiche votre capture. Le lien n'est pas versionné, car /public/storage figure dans le .gitignore du kit : ajoutez la commande aux étapes d'installation de votre README.

Le contenu de storage/app/public/ n'est pas versionné non plus. Son .gitignore contient *, parce que les fichiers téléversés par les visiteurs d'un site n'ont rien à faire dans Git. Vos captures du seeder, elles, font partie du projet : sans elles, un clone de votre dépôt affiche des projets sans image après migrate:fresh --seed. Remplacez le contenu de storage/app/public/.gitignore par ces quatre lignes :

*
!.gitignore
!images/
!images/**

Tout reste ignoré, sauf le fichier .gitignore lui-même et le dossier images/ avec son contenu. Le dossier projects/ du chapitre 1.14 restera ignoré, comme il se doit.

Le code

Le portfolio d'exemple du cours enregistre trois projets : « Météo Wavre », « Quiz des capitales » et « Carnet de recettes ». Voici un extrait de son ProjectSeeder.php : les étiquettes et le projet « Quiz des capitales », avec un contenu raccourci à un paragraphe.

public function run(): void
{
    $vue = Tag::create(['name' => 'Vue.js', 'slug' => 'vue-js']);
    $laravel = Tag::create(['name' => 'Laravel', 'slug' => 'laravel']);
    $javascript = Tag::create(['name' => 'JavaScript', 'slug' => 'javascript']);

    $quiz = Project::create([
        'title' => 'Quiz des capitales',
        'slug' => 'quiz-des-capitales',
        'summary' => 'Un quiz de géographie en Vue.js avec score, minuteur et classement local.',
        'content' => '<p>Projet du cours d\'interfaces : composants Vue, état partagé et stockage local du meilleur score.</p>',
        'cover_path' => 'images/quiz-capitales.jpg',
        'demo_url' => 'https://example.com/quiz',
        'repo_url' => 'https://github.com/exemple/quiz-capitales',
        'published_at' => now(),
        'position' => 2,
    ]);
    $quiz->tags()->attach([$vue->id, $javascript->id]);

    // « Météo Wavre » : slug meteo-wavre, étiquette JavaScript, position 1
    // « Carnet de recettes » : slug carnet-de-recettes, étiquette Laravel, position 3, demo_url null
}

Importez App\Models\Project et App\Models\Tag en tête du fichier. Votre seeder enregistre vos trois projets, pas ceux de l'exemple.

Le seeder crée d'abord les étiquettes, puis chaque projet. $quiz->tags()->attach([...]) insère une ligne dans project_tag pour chaque numéro d'étiquette reçu, comme guests()->attach() au festival. Reprenez les étiquettes de chaque fiche, et créez chaque étiquette une seule fois, même si plusieurs projets la portent.

Chaque rubrique de la fiche trouve sa colonne.

Fiche projet Colonne
Titre title, et le slug que vous en tirez à la main
Résumé summary
Contexte, rôle, problème, choix, résultat, ce que je referais content, une rubrique par paragraphe <p>
Capture d'ensemble cover_path
Lien démo, lien code demo_url, repo_url, ou null s'il n'y en a pas
Étiquettes Tag::create() puis attach()

Le contenu est du HTML écrit dans une chaîne PHP entre apostrophes. Une apostrophe du texte ferme la chaîne trop tôt : écrivez-la \', comme dans d\'interfaces. Ce HTML écrit à la main est provisoire. L'éditeur de texte riche du chapitre 1.15 le remplacera.

published_at reçoit now(), car vos trois projets sont publiés. position donne l'ordre d'affichage : 1 pour le projet à montrer en premier.

Si vous n'avez pas apporté vos textes et vos captures, ouvrez les trois projets de secours ci-dessous. Vous les remplacerez par les vôtres avant la remise.

Téléchargez meteo-wavre.jpg, quiz-capitales.jpg et carnet-recettes.jpg, puis copiez-les dans storage/app/public/images/.

Météo Wavre

Une page qui affiche la météo de Wavre heure par heure à partir d'une API publique.
Étiquettes : JavaScript.

Contexte. Projet du cours de scripts clients. La consigne : lire en JavaScript les données d'une API publique, un service en ligne qui répond en JSON.

Rôle. Seul, du HTML à la mise en ligne.

Problème. Une seule page, lisible sur téléphone et sans publicité, qui montre la température et le risque de pluie à Wavre pour les douze prochaines heures.

Choix techniques. L'API Open-Meteo, gratuite et sans clé, appelée avec fetch. Un graphique des températures en SVG dessiné à la main, pour ne charger aucune bibliothèque. Un mode sombre qui suit le réglage du téléphone.

Résultat. Douze heures de prévisions réelles, mises à jour à chaque visite, en ligne sur GitHub Pages.

Ce que je referais. Quand l'API ne répond pas, la page reste blanche, sans message. J'afficherais une erreur claire et je laisserais choisir une autre commune.

Quiz des capitales

Un quiz de géographie en Vue.js avec score, minuteur et classement local.
Étiquettes : Vue.js, JavaScript.

Contexte. Projet du cours d'interfaces, où j'ai découvert Vue.js. La consigne demandait un petit jeu en plusieurs composants.

Rôle. Seul : les questions, les composants et le style.

Problème. Dix questions par partie, quinze secondes par question, le score visible à tout moment, et les meilleurs scores retrouvés le lendemain sans créer de compte.

Choix techniques. Vue.js 3 avec Vite, en trois composants : la question, le minuteur et le tableau des scores. Les trois lisent le même objet réactif, rangé dans un fichier à part. Les cinq meilleurs scores restent dans le localStorage, la mémoire du navigateur.

Résultat. Le quiz marche sur ordinateur et sur téléphone, avec quarante pays d'Europe. Le classement reste dans un seul navigateur.

Ce que je referais. Une partie peut poser deux fois la même question. Je tirerais les pays sans remise, et j'écrirais des tests.

Carnet de recettes

Une application Laravel pour classer ses recettes par saison et par ingrédient.
Étiquettes : Laravel.

Contexte. Projet libre du cours de framework côté serveur. Les recettes de ma famille dormaient dans un classeur et dans des photos sur nos téléphones.

Rôle. Seul : la base de données, les migrations, les contrôleurs, les vues et les tests.

Problème. Une recette demande plusieurs ingrédients, et un ingrédient sert dans plusieurs recettes. Il fallait retrouver toutes les recettes au potiron, ou seulement celles d'automne.

Choix techniques. Laravel 13, Blade et Tailwind CSS. Une table pivot entre recettes et ingrédients, qui garde la quantité. Une Form Request pour l'ajout et la modification. Des tests Pest pour les deux filtres.

Résultat. L'ajout, la modification, la suppression et les deux filtres fonctionnent, et les tests passent. L'application n'est pas en ligne.

Ce que je referais. La quantité est un texte libre, comme « 200 g ». L'application ne peut pas recalculer une recette pour six personnes. Je stockerais un nombre et une unité séparés.

Le seeder des trois projets

Il remplace tout le contenu de database/seeders/ProjectSeeder.php. Le point entre deux paragraphes colle les chaînes PHP bout à bout.

<?php

namespace Database\Seeders;

use App\Models\Project;
use App\Models\Tag;
use Illuminate\Database\Seeder;

class ProjectSeeder extends Seeder
{
    public function run(): void
    {
        $vue = Tag::create(['name' => 'Vue.js', 'slug' => 'vue-js']);
        $laravel = Tag::create(['name' => 'Laravel', 'slug' => 'laravel']);
        $javascript = Tag::create(['name' => 'JavaScript', 'slug' => 'javascript']);

        $meteo = Project::create([
            'title' => 'Météo Wavre',
            'slug' => 'meteo-wavre',
            'summary' => 'Une page qui affiche la météo de Wavre heure par heure à partir d\'une API publique.',
            'content' => '<p>Projet du cours de scripts clients à l\'IFOSUP Wavre. Le sujet était libre, avec une contrainte : lire en JavaScript les données d\'une API publique, un service en ligne qui répond en JSON. Je voulais savoir s\'il fallait prendre un parapluie pour venir au cours.</p>'
                .'<p>Seul, du HTML à la mise en ligne. Le professeur fournissait seulement la liste des API autorisées.</p>'
                .'<p>Les sites de météo affichent beaucoup de publicités et de menus. Je voulais une seule page, lisible sur téléphone, qui montre la température et le risque de pluie à Wavre pour les douze prochaines heures.</p>'
                .'<p>L\'API Open-Meteo, parce qu\'elle est gratuite et ne demande pas de clé. Un appel avec <code>fetch</code>, puis une boucle qui construit une ligne par heure. Le graphique des températures est un petit SVG que je dessine moi-même, pour ne charger aucune bibliothèque. Le mode sombre suit le réglage du téléphone, grâce à la règle CSS <code>prefers-color-scheme</code>.</p>'
                .'<p>La page affiche douze heures de prévisions, mises à jour à chaque visite. Elle est en ligne sur GitHub Pages. Les prévisions sont réelles, mais seulement pour Wavre.</p>'
                .'<p>Quand l\'API ne répond pas, la page reste blanche, sans aucun message. J\'afficherais une erreur claire et la dernière prévision reçue. Je laisserais aussi choisir une autre commune que Wavre.</p>',
            'cover_path' => 'images/meteo-wavre.jpg',
            'demo_url' => 'https://example.com/meteo',
            'repo_url' => 'https://github.com/exemple/meteo-wavre',
            'published_at' => now(),
            'position' => 1,
        ]);
        $meteo->tags()->attach([$javascript->id]);

        $quiz = Project::create([
            'title' => 'Quiz des capitales',
            'slug' => 'quiz-des-capitales',
            'summary' => 'Un quiz de géographie en Vue.js avec score, minuteur et classement local.',
            'content' => '<p>Projet du cours d\'interfaces à l\'IFOSUP Wavre, où j\'ai découvert Vue.js. La consigne demandait un petit jeu en plusieurs composants. J\'ai choisi un quiz sur les capitales d\'Europe, un sujet que tout le monde comprend sans explication.</p>'
                .'<p>Seul. J\'ai écrit les questions, les composants et le style.</p>'
                .'<p>Une partie compte dix questions. Le joueur a quinze secondes par question et voit son score à tout moment. Quand il revient le lendemain, il retrouve ses meilleurs scores, sans créer de compte.</p>'
                .'<p>Vue.js 3 avec Vite, en trois composants : la question, le minuteur et le tableau des scores. Les trois lisent le même objet réactif, rangé dans un fichier à part. Quand le score change dans cet objet, chaque composant se met à jour sans code en plus. Les cinq meilleurs scores restent dans le <code>localStorage</code>, la mémoire du navigateur.</p>'
                .'<p>Le quiz marche sur ordinateur et sur téléphone. Les quarante pays viennent d\'un fichier JSON que j\'ai rempli à la main. Le classement reste dans un seul navigateur : il n\'est pas partagé entre les joueurs.</p>'
                .'<p>Le tirage au sort peut poser deux fois la même question dans une partie. Je tirerais les dix pays sans remise. J\'écrirais aussi des tests, car je n\'en ai écrit aucun.</p>',
            'cover_path' => 'images/quiz-capitales.jpg',
            'demo_url' => 'https://example.com/quiz',
            'repo_url' => 'https://github.com/exemple/quiz-capitales',
            'published_at' => now(),
            'position' => 2,
        ]);
        $quiz->tags()->attach([$vue->id, $javascript->id]);

        $recipes = Project::create([
            'title' => 'Carnet de recettes',
            'slug' => 'carnet-de-recettes',
            'summary' => 'Une application Laravel pour classer ses recettes par saison et par ingrédient.',
            'content' => '<p>Projet libre du cours de framework côté serveur à l\'IFOSUP Wavre. Ma famille garde ses recettes dans un classeur et dans des photos sur nos téléphones. Je voulais les réunir dans une application, et retrouver une recette à partir de ce qui reste dans le frigo.</p>'
                .'<p>Seul. J\'ai conçu la base de données et écrit les migrations, les contrôleurs, les vues et les tests.</p>'
                .'<p>Une recette demande plusieurs ingrédients, et un ingrédient sert dans plusieurs recettes. Je devais pouvoir afficher toutes les recettes qui contiennent du potiron, ou seulement celles d\'automne.</p>'
                .'<p>Laravel 13 avec des templates Blade et Tailwind CSS. Une table pivot relie les recettes et les ingrédients, et garde la quantité de chaque ingrédient. Une Form Request valide l\'ajout et la modification avec les mêmes règles. Des tests Pest vérifient les deux filtres, par saison et par ingrédient.</p>'
                .'<p>L\'ajout, la modification, la suppression et les deux filtres fonctionnent, et les tests passent. Les vingt recettes de démonstration viennent du classeur familial. L\'application tourne seulement sur mon ordinateur : je ne l\'ai pas mise en ligne.</p>'
                .'<p>La quantité est un texte libre, comme « 200 g » ou « une pincée ». L\'application ne peut donc pas recalculer une recette pour six personnes au lieu de quatre. Je stockerais un nombre et une unité dans deux colonnes séparées.</p>',
            'cover_path' => 'images/carnet-recettes.jpg',
            'demo_url' => null,
            'repo_url' => 'https://github.com/exemple/carnet-recettes',
            'published_at' => now(),
            'position' => 3,
        ]);
        $recipes->tags()->attach([$laravel->id]);
    }
}

Appeler le seeder

DatabaseSeeder crée déjà l'utilisateur du kit. Gardez cette partie, et ajoutez une ligne à la fin de sa méthode run() :

$this->call(ProjectSeeder::class);

8. migrate:fresh --seed, à chaque changement de schéma

Lancez les migrations et les seeders :

php artisan migrate:fresh --seed
  …
  2026_10_25_122829_create_projects_table ........................ 0.90ms DONE
  2026_10_25_122830_create_tags_table ............................ 1.98ms DONE
  2026_10_25_122831_create_project_tag_table ..................... 0.55ms DONE

   INFO  Seeding database.

  …
  Database\Seeders\ProjectSeeder .................................. 22 ms DONE

Les migrations du kit passent en premier, coupées ici. Vos trois migrations suivent, dans l'ordre de leur horodatage, puis le seeder.

migrate:fresh supprime toutes les tables, relance toutes les migrations depuis le début, puis --seed lance DatabaseSeeder. La base est vidée à chaque fois, et c'est voulu : vos données de développement viennent du seeder, qui les recrée à l'identique.

Prenez le réflexe : chaque fois que vous modifiez une migration ou le seeder, relancez php artisan migrate:fresh --seed. Une colonne ajoutée dans une migration déjà lancée n'apparaît pas sans cette commande.

En production, la commande refuse de tourner. Le AppServiceProvider du kit appelle DB::prohibitDestructiveCommands(app()->isProduction()), qui bloque les commandes qui vident la base quand l'application tourne en production.

9. Vérifier avec Tinker

Tinker lance du PHP dans votre application, avec vos modèles et votre base. L'option --execute exécute une seule instruction puis rend la main. Comptez les projets :

php artisan tinker --execute="echo App\Models\Project::count();"
3

Listez les titres dans l'ordre d'affichage :

php artisan tinker --execute="echo App\Models\Project::orderBy('position')->pluck('title')->implode(', ');"
Météo Wavre, Quiz des capitales, Carnet de recettes

pluck('title') garde une seule colonne de chaque ligne, et implode(', ') les colle avec une virgule. Vérifiez enfin les étiquettes d'un projet, à travers la relation :

php artisan tinker --execute="echo App\Models\Project::firstWhere('slug', 'quiz-des-capitales')->tags->pluck('name')->implode(', ');"
Vue.js, JavaScript

firstWhere('slug', 'quiz-des-capitales') renvoie le premier projet dont le slug vaut quiz-des-capitales. ->tags, sans parenthèses, lit les étiquettes reliées dans project_tag. Remplacez le slug par celui d'un de vos projets.

Astuce

Écrivez l'instruction entre guillemets doubles et les chaînes PHP entre apostrophes, comme ci-dessus. Cette écriture marche dans PowerShell comme dans Git Bash. Dans PowerShell, l'inverse, des apostrophes autour et des guillemets dedans, perd les guillemets en route. Tinker répond alors par une erreur, comme PARSE ERROR ou Undefined constant "slug".

10. Pratique : vos trois projets dans la base (35 min)

Mettez vos trois projets dans la base de votre portfolio.

  1. Les migrations de projects, tags et project_tag, avec nullable() là où votre diagramme met [0..1].
  2. Les modèles Project et Tag, avec #[Fillable] et la relation belongsToMany des deux côtés.
  3. La fabrique ProjectFactory avec son état draft(), essayée avec make() dans Tinker.
  4. Vos trois captures dans storage/app/public/images/, nommées en minuscules, sans espace ni accent ; le lien storage:link créé et le .gitignore du dossier qui garde images/.
  5. ProjectSeeder avec vos trois projets et leurs étiquettes, appelé par DatabaseSeeder.

Vérifiez avec les trois commandes Tinker de la section 9 : la base contient trois projets, vos titres sortent dans l'ordre voulu, et un projet porte les bonnes étiquettes. Dans le navigateur, http://portfolio.test/storage/images/quiz-capitales.jpg, avec le nom d'une de vos captures, affiche l'image. Si migrate:fresh --seed s'arrête sur une erreur, lisez le nom de la colonne ou de la table cité dans le message, puis relisez la migration ou le seeder à cet endroit.

Ce que votre portfolio contient maintenant

Trois tables de plus, projects, tags et project_tag, et deux modèles reliés. Une fabrique prête pour les tests. Vos trois projets, avec leurs textes, leurs étiquettes et leurs captures, recréés par une seule commande. Les captures sont sur le disque public, versionnées avec le code et visibles sous /storage/images/. Au chapitre suivant, ils apparaissent dans le navigateur.

À retenir

  • Chaque classe du diagramme devient une table, et un attribut [0..1] devient une colonne nullable().
  • La migration de la table pivot se crée après celles des deux tables qu'elle relie, car l'horodatage du fichier fixe l'ordre.
  • #[Fillable] liste les colonnes que create() peut remplir, et belongsToMany() relie Project et Tag à travers project_tag.
  • La fabrique invente des projets pour les tests, alors que le seeder enregistre vos vrais projets avec leurs textes et leurs captures.
  • Les captures du seeder vivent sur le disque public, dans storage/app/public/images/ : php artisan storage:link les rend visibles sous /storage/, et quatre lignes de .gitignore les gardent dans Git.
  • Après chaque changement de migration ou de seeder, vous relancez php artisan migrate:fresh --seed et vous vérifiez avec Tinker.