Pages et props : la liste et la fiche d'un projet, brutes
Temps 1 · séance 2 · ~60 min
Les slides du chapitre (PDF)PDF
- Passer les projets de la base à une page Vue avec
Inertia::render. - Choisir les props qu'une page reçoit, et seulement celles-là.
- Retrouver un projet par son slug grâce à la liaison de modèle.
- Afficher une liste avec
v-foret:key.
Le code qui sent mauvais
En binôme, cinq minutes. Cette méthode affiche la fiche d'un projet, et la page Vue ne montre que le titre, le résumé et le contenu.
public function show(Project $project): Response
{
return Inertia::render('projects/Show', [
'project' => $project,
]);
}
Qu'est-ce qu'un visiteur peut lire en plus du titre, du résumé et du contenu ? Indice : au chapitre 1.04, vous avez trouvé la prop author dans le code source de la page.
1. Le contrôleur des pages publiques
Les pages publiques des projets ont leur propre contrôleur. Créez-le :
php artisan make:controller ProjectController
Sa méthode index() prépare la liste des projets. Écrivez-la dans app/Http/Controllers/ProjectController.php, avec use App\Models\Project;, use Illuminate\Support\Facades\Storage;, use Inertia\Inertia; et use Inertia\Response; en tête du fichier :
public function index(): Response
{
$projects = Project::with('tags')
->orderBy('position')
->get()
->map(fn (Project $project) => [
'title' => $project->title,
'slug' => $project->slug,
'summary' => $project->summary,
'cover_url' => $project->cover_path
? Storage::disk('public')->url($project->cover_path)
: null,
'tags' => $project->tags->pluck('name'),
]);
return Inertia::render('projects/Index', [
'projects' => $projects,
]);
}
Suivez la requête de haut en bas.
Project::with('tags')lit les projets et charge leurs étiquettes en une requête de plus, au lieu d'une requête par projet. Vous avez rencontré ce piège N+1 au festival de 5XCOS.orderBy('position')trie les projets selon la colonne que votre seeder a remplie.get()lance la requête et renvoie une collection de modèlesProject.map()transforme chaque modèle en un tableau de cinq clés. La fonction fléchéefn (Project $project) => [...]reçoit un projet et renvoie son tableau.
Deux clés ne recopient pas une colonne. cover_url calcule l'adresse complète de l'image : Storage::disk('public')->url() met devant images/quiz-capitales.jpg l'adresse du site, lue dans APP_URL, puis /storage/, le lien créé au chapitre 1.06. Le résultat, http://portfolio.test/storage/images/quiz-capitales.jpg, est l'adresse que vous avez ouverte à la fin du chapitre précédent. Un projet sans image reçoit null : le ? et le : forment une condition en une seule expression, coupée sur trois lignes pour se lire. tags ne garde que le nom de chaque étiquette : pluck('name') parcourt la collection $project->tags et tire la colonne name de chaque étiquette, dans l'ordre, soit ["Vue.js","JavaScript"] pour le quiz. Sans pluck(), chaque étiquette partirait entière, avec son id, son slug, ses dates et sa ligne de pivot. Tinker le montre :
php artisan tinker --execute="echo App\Models\Project::firstWhere('slug', 'quiz-des-capitales')->tags->pluck('name')->toJson();"
["Vue.js","JavaScript"]
Inertia::render('projects/Index', [...]) choisit la page et lui passe ses props. Le nom de la page est le chemin du fichier sous resources/js/pages, sans l'extension : projects/Index désigne resources/js/pages/projects/Index.vue. Le dossier s'écrit en minuscules, comme les dossiers auth et settings du kit.
2. Pourquoi des props explicites
Tout ce que vous passez à Inertia::render arrive dans le navigateur. Laravel écrit les props dans une balise <script data-page> du HTML de la page. Au chapitre 1.08, les liens d'Inertia les recevront en JSON, sans recharger la page. Dans les deux cas, n'importe quel visiteur les lit.
Le modèle entier contient bien plus que ce que la page affiche. Tinker liste ses clés :
php artisan tinker --execute="echo implode(', ', array_keys(App\Models\Project::with('tags')->firstWhere('slug', 'quiz-des-capitales')->toArray()));"
id, title, slug, summary, content, cover_path, demo_url, repo_url, published_at, position, created_at, updated_at, tags
Chaque étiquette chargée apporte en plus ses propres colonnes et la ligne de la table pivot :
php artisan tinker --execute="echo App\Models\Project::with('tags')->firstWhere('slug', 'quiz-des-capitales')->tags->first();"
{"id":1,"name":"Vue.js","slug":"vue-js","created_at":"2026-10-27T08:43:38.000000Z","updated_at":"2026-10-27T08:43:38.000000Z","pivot":{"project_id":2,"tag_id":1}}
Avec 'project' => $project, toutes ces colonnes partiraient vers le navigateur, et les étiquettes avec dès qu'elles sont chargées. Avec un tableau construit, la page reçoit seulement ce qu'elle affiche. Une prop est explicite quand vous l'avez choisie et nommée dans le contrôleur.

- Rien ne fuit par accident. Une colonne ajoutée plus tard, une note privée ou une date de brouillon, n'arrive pas dans le navigateur tant que vous ne l'ajoutez pas au tableau.
- La page reçoit des données prêtes. L'adresse de l'image est calculée côté serveur, et la page n'a pas à savoir où les images sont rangées.
- Le contrat se lit en un endroit. Les clés du tableau sont exactement les props que la page déclare avec
defineProps.
Une page reçoit un tableau que vous avez construit, jamais un modèle entier.
3. Les routes et la liaison de modèle
Ajoutez deux routes dans routes/web.php, avec use App\Http\Controllers\ProjectController; en tête :
Route::get('projets', [ProjectController::class, 'index'])->name('projects.index');
Route::get('projets/{project:slug}', [ProjectController::class, 'show'])->name('projects.show');
L'adresse est en français, le nom de la route, le contrôleur et la page sont en anglais.
Vous connaissez la liaison de modèle depuis 5XCOS. Un paramètre {product} et un argument Product $product du même nom : Laravel cherche le produit dont l'id vaut la valeur de l'adresse. {project:slug} change la colonne de recherche. Pour /projets/quiz-des-capitales, Laravel cherche le projet dont la colonne slug vaut quiz-des-capitales, puis le passe à show().
Un slug inconnu ne trouve aucune ligne. Laravel répond alors par une erreur 404, sans une ligne de code de votre part. Vérifiez vos deux routes :
php artisan route:list --path=projets
…
GET|HEAD projets .. projects.index › ProjectController@index
GET|HEAD projets/{project:slug} .. projects.show › ProjectController@show
Showing [11] routes
Le portfolio du cours a déjà les neuf routes du back-office, sous admin/projets, coupées ici par …. Vous les ajouterez au chapitre 1.10. Dans votre projet, la commande s'arrête aujourd'hui aux deux routes publiques.
4. La fiche : show()
La méthode show() reçoit le projet déjà trouvé par la liaison de modèle :
public function show(Project $project): Response
{
$project->load('tags');
return Inertia::render('projects/Show', [
'project' => [
...$project->only('title', 'slug', 'summary', 'content', 'demo_url', 'repo_url'),
'cover_url' => $project->cover_path
? Storage::disk('public')->url($project->cover_path)
: null,
'tags' => $project->tags->pluck('name'),
],
]);
}
Le projet est déjà lu, donc with() n'a plus de sens. load('tags') charge ses étiquettes après coup, sur l'objet que vous avez en main.
$project->only(...) renvoie un tableau PHP avec les seules colonnes nommées : six clés, six valeurs, et rien du reste du modèle.
php artisan tinker --execute="echo json_encode(App\Models\Project::firstWhere('slug', 'quiz-des-capitales')->only('title', 'slug', 'demo_url'), JSON_UNESCAPED_SLASHES);"
{"title":"Quiz des capitales","slug":"quiz-des-capitales","demo_url":"https://example.com/quiz"}
Les trois points ... devant l'appel déballent ce tableau : ses clés sont recopiées, une à une, dans le tableau qui l'entoure, au même niveau que cover_url et tags que vous ajoutez ensuite. Sans les trois points, le tableau de only() serait posé entier dans le vôtre, sous la clé 0, et la page devrait lire project[0].title. Deux tableaux de rien le montrent :
php artisan tinker --execute="echo json_encode([...['a' => 1, 'b' => 2], 'c' => 3]);"
php artisan tinker --execute="echo json_encode([['a' => 1, 'b' => 2], 'c' => 3]);"
{"a":1,"b":2,"c":3}
{"0":{"a":1,"b":2},"c":3}
Avec les trois points, a et b sont au même niveau que c. Sans eux, ils sont dans une boîte à l'intérieur de la boîte.
Voici les props que reçoit la fiche du quiz, lues dans le code source de la fiche et coupées par … :
<script data-page="app" type="application/json">{"component":"projects\/Show","props":{"errors":{},"name":"Laravel","auth":{"user":null},"sidebarOpen":true,"project":{"title":"Quiz des capitales","slug":"quiz-des-capitales","summary":"Un quiz de géographie en Vue.js avec score, minuteur et classement local.","content":"\u003cp>Projet du cours …\u003c\/p>","demo_url":"https:\/\/example.com\/quiz","repo_url":"https:\/\/github.com\/exemple\/quiz-capitales","cover_url":"http:\/\/…\/quiz-capitales.jpg","tags":["Vue.js","JavaScript"]}},…
Le projet contient huit clés, et aucune n'est id, cover_path ou created_at. Les quatre premières props, errors, name, auth et sidebarOpen, sont les props partagées du kit, que chaque page reçoit. name porte le nom du site, Laravel tant que vous n'avez pas changé APP_NAME dans .env, ce que vous ferez au chapitre 1.08. Les \/ sont des barres obliques échappées par l'encodage JSON.
Dans ce code source, chaque < du contenu est remplacé par son code, \u003c. C'est Inertia qui encode l'objet de page ainsi, et ce remplacement protège la balise <script> : si le contenu d'un projet contenait le texte </script>, il arriverait écrit \u003c/script> et ne pourrait pas fermer la balise trop tôt. Le navigateur, lui, lit ce code comme le caractère qu'il représente. Sans Vite, en production, Laravel encode davantage : > devient \u003E et é devient \u00e9. Le résultat à l'écran est le même.
5. La page de la liste
Créez resources/js/pages/projects/Index.vue :
<script setup>
defineProps({
projects: Array,
});
</script>
<template>
<h1>Mes projets</h1>
<ul>
<li v-for="project in projects" :key="project.slug">
<img v-if="project.cover_url" :src="project.cover_url" alt="" width="300" />
<h2>{{ project.title }}</h2>
<p>{{ project.summary }}</p>
<a :href="`/projets/${project.slug}`">Voir le projet</a>
</li>
</ul>
</template>
defineProps({ projects: Array }) déclare la prop que le contrôleur envoie, avec le même nom.
La directive v-for répète l'élément qui la porte, une fois par élément de la liste. project in projects donne à chaque tour le projet courant, que le contenu du <li> utilise. Les deux points devant un attribut, comme dans :src ou :href, demandent à Vue de calculer sa valeur à partir des données au lieu de l'écrire telle quelle.
:key donne à chaque <li> un identifiant unique et stable. Quand la liste change, Vue s'en sert pour savoir quel élément a bougé, et il réutilise le bon élément au lieu de tout reconstruire. Le slug convient : il est unique dans la base, et il ne change pas d'un affichage à l'autre.
La directive v-if n'affiche l'élément que si la valeur existe. Un projet sans image n'affiche donc pas d'image cassée.
L'attribut alt donne le texte qu'un lecteur d'écran, le logiciel qui lit la page à voix haute, annonce à la place de l'image. Le titre est juste à côté, l'image n'apporte pas de texte de plus : alt="" dit au lecteur d'écran de la passer.
L'adresse du lien est une chaîne entre accents graves, que JavaScript appelle un littéral de gabarit (template literal) : ${project.slug} y est remplacé par la valeur. Cette adresse écrite en dur dans la page sera remplacée au chapitre 1.08.

6. La page de la fiche
Créez resources/js/pages/projects/Show.vue :
<script setup>
defineProps({
project: Object,
});
</script>
<template>
<article>
<h1>{{ project.title }}</h1>
<p>{{ project.summary }}</p>
<img v-if="project.cover_url" :src="project.cover_url" alt="" width="600" />
<ul>
<li v-for="tag in project.tags" :key="tag">{{ tag }}</li>
</ul>
<div v-html="project.content"></div>
<a v-if="project.demo_url" :href="project.demo_url">Démo</a>
<a v-if="project.repo_url" :href="project.repo_url">Code</a>
</article>
</template>

Les étiquettes sont de simples chaînes, toutes différentes pour un même projet. Chacune sert donc de clé : :key="tag".
{{ project.content }} afficherait les balises comme du texte, <p> compris. La directive v-html insère le contenu comme du HTML, et le navigateur affiche des paragraphes. Elle insère tout ce qu'elle reçoit, sans filtre. Pour l'instant, ce HTML vient de votre seeder, que vous avez écrit vous-même. Au chapitre 1.15, il viendra de votre éditeur, et vous verrez pourquoi afficher sans filtre reste acceptable tant que vous êtes le seul à écrire.
v-if n'affiche l'élément que si la valeur existe. L'image, la démo et le code sont tous trois facultatifs. Le « Carnet de recettes » de l'exemple n'a pas de démo, et sa fiche montre seulement le lien vers le code.
Les deux pages n'ont aucun style. La mise en forme viendra avec votre direction artistique, au chapitre 1.18.
7. Ce qui casse, et pourquoi
La barre latérale d'administration autour de la page
Ouvrez portfolio.test/projets sans être connecté. La page reste blanche, et la console du navigateur affiche l'erreur du chapitre 1.04 :
TypeError: Cannot read properties of null (reading 'name')
Le résolveur de layouts de resources/js/app.ts donne le layout d'administration du kit, AppLayout, avec sa barre latérale « Platform / Dashboard », à toute page qu'il ne reconnaît pas. Ce layout affiche le nom de l'utilisateur connecté, et auth.user vaut null pour un visiteur.
Au chapitre 1.04, vous avez ajouté Home au cas qui renvoie null. Ajouter chaque page publique à ce cas devient vite pénible. Inversez plutôt la règle : le layout d'administration pour Dashboard et les pages admin/…, rien par défaut. Remplacez le résolveur entier :
layout: (name) => {
switch (true) {
case name.startsWith('auth/'):
return AuthLayout;
case name.startsWith('settings/'):
return [AppLayout, SettingsLayout];
case name === 'Dashboard' || name.startsWith('admin/'):
return AppLayout;
default:
return null;
}
},
switch (true) teste chaque case dans l'ordre et s'arrête au premier qui vaut true. return null signifie « pas de layout ». Votre page Home tombe maintenant dans le cas default, avec les projets. Au chapitre 1.08, ce default recevra votre propre layout.
Du texte clair sur fond noir
Si votre système est en mode sombre, la page brute s'affiche en texte clair sur fond noir, avec des titres de la même taille que le texte et des listes sans puces. Ce n'est pas un bug. Le kit pose la classe dark sur la balise <html> selon les préférences du système, et Tailwind remet à zéro les styles par défaut du navigateur.
La page introuvable dans le manifeste de Vite
Si Vite ne tourne pas, la nouvelle page répond par une erreur 500 et le message Unable to locate file in Vite manifest, vu au chapitre 1.04. Relancez composer run dev, qui démarre Vite avec le reste.
8. Pratique : la liste et la fiche (30 min)
Affichez vos trois projets dans le navigateur.
ProjectControlleravecindex()etshow(), chacun avec un tableau de props construit.- Les deux routes,
/projetset/projets/{project:slug}, nomméesprojects.indexetprojects.show. - Les pages
projects/Index.vueetprojects/Show.vue, brutes. - Le résolveur de
app.tsinversé.
Vérifiez dans le navigateur.
portfolio.test/projetsmontre vos trois projets dans l'ordre deposition, chacun avec son image, son titre, son résumé et un lien.- Le lien ouvre
/projets/suivi du slug, avec les étiquettes, le contenu en paragraphes et les liens. Un projet sans démo n'affiche pas le lien « Démo ». portfolio.test/projets/nexiste-pasrépond par une erreur 404.- Dans le code source de la fiche, cherchez
"project":: vous ne trouvez ni"id", nicover_path, nicreated_at.
Ce que votre portfolio contient maintenant
Une liste de vos projets à l'adresse /projets et une fiche par projet à l'adresse de son slug, sans style. Chaque page reçoit seulement les props que vous avez choisies. Les pages publiques ne reçoivent plus le layout d'administration.
À retenir
Inertia::render('projects/Index', [...])choisit la page par son chemin sousresources/js/pageset lui passe ses props.- Toutes les props sont lisibles dans le navigateur, donc vous construisez un tableau, champ par champ dans
map()ou aveconly(), au lieu de passer le modèle entier. only()rend un tableau des seules colonnes nommées, les trois points...le déballent dans le vôtre, etpluck('name')tire une colonne d'une collection.{project:slug}fait chercher le projet par son slug, et un slug inconnu répond par une 404.with('tags')charge les étiquettes de toute la liste en une requête, etload('tags')celles du projet déjà lu.v-forrépète un élément pour chaque projet, et:keylui donne un identifiant unique et stable, ici le slug.