Modèles, migrations, fabriques, seeders : vos trois projets dans la base
Temps 1 · séance 2 · ~70 min
- Traduire le diagramme de classes en trois migrations, table pivot comprise.
- Écrire les modèles
ProjectetTaget 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()surslug. 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 colonnestringaccepte 255 caractères par défaut. Le second argument porte la limite à 500, une marge confortable pour un résumé d'une phrase.longTextaccepte un texte beaucoup plus long questring. Le contenu sera du HTML, avec ses balises, et il peut devenir long.->default(0)surposition. 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 versprojects.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.
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.
- Les migrations de
projects,tagsetproject_tag, avecnullable()là où votre diagramme met[0..1]. - Les modèles
ProjectetTag, avec#[Fillable]et la relationbelongsToManydes deux côtés. - La fabrique
ProjectFactoryavec son étatdraft(), essayée avecmake()dans Tinker. - Vos trois captures dans
storage/app/public/images/, nommées en minuscules, sans espace ni accent ; le lienstorage:linkcréé et le.gitignoredu dossier qui gardeimages/. ProjectSeederavec vos trois projets et leurs étiquettes, appelé parDatabaseSeeder.
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 colonnenullable(). - 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 quecreate()peut remplir, etbelongsToMany()relieProjectetTagà traversproject_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, dansstorage/app/public/images/:php artisan storage:linkles rend visibles sous/storage/, et quatre lignes de.gitignoreles gardent dans Git. - Après chaque changement de migration ou de seeder, vous relancez
php artisan migrate:fresh --seedet vous vérifiez avec Tinker.