CRUD projets
Du modèle à l'accueil : le CRUD projets
L'énoncé est costaud, alors plutôt que de te jeter dans le grand bain, ce chapitre te tient la main pour un premier tour complet : on crée le modèle Project, on génère des données factices, on construit les pages d'admin (liste, création, édition, suppression), et on termine sur une homepage publique. Une fois ce squelette en place, le reste de l'énoncé (technos, expériences, blog, etc.) c'est la même recette à appliquer.
Générer les fichiers d'un coup
On commence par tout générer en une commande grâce aux flags d'Artisan :
php artisan make:model Project -mfsr
Ce qui crée le modèle, la migration, la factory, le seeder et le contrôleur resource. Cinq fichiers, une ligne.
Migration et modèle
Dans la migration, on ajoute les deux champs qui décrivent un projet :
Schema::create('projects', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('description');
$table->timestamps();
});
Puis on autorise l'assignation de masse dans le modèle, sinon Laravel refusera d'enregistrer quoi que ce soit :
class Project extends Model
{
use HasFactory;
protected $fillable = ['title', 'description'];
}
Factory et seeder
La factory définit à quoi ressemble un projet bidon :
public function definition()
{
return [
'title' => fake()->bs(),
'description' => fake()->paragraphs(3, true),
];
}
💡
fake()->bs()génère du jargon corporate (« synergize scalable solutions »). Parfait pour des titres de projet placeholder.
Le seeder appelle la factory pour générer dix projets :
public function run()
{
Project::factory(10)->create();
}
Et on enregistre le ProjectSeeder dans le DatabaseSeeder pour qu'il tourne avec les autres :
public function run()
{
// ... autres seeders
$this->call([
ProjectSeeder::class,
]);
}
Pour repartir d'une base propre avec les données factices :
php artisan migrate:fresh --seed
⚠️
migrate:freshdrop toutes les tables. Ne lance jamais ça en production. (Si tu te poses la question, c'est probablement déjà trop tard.)
Routes
Une seule ligne pour câbler les sept routes CRUD :
// ... autres routes
Route::prefix("admin")->middleware(['auth', 'verified'])->group(function () {
Route::resource('projects', ProjectController::class);
});
Toutes les routes sont derrière auth + verified, donc inaccessibles aux invités. Le DatabaseSeeder crée un user de test (test@example.com / password) — utilise-le pour te connecter.
💡 Ces imports magiques comme
@/routes/projectset@/actions/App/Http/Controllers/ProjectControllerqu'on va utiliser plus bas, c'est Wayfinder qui les génère automatiquement à partir de tes routes Laravel. Plus besoin de hardcoder des URL côté front.
Le contrôleur : méthode index
On commence par la liste des projets :
public function index()
{
$projects = Project::all();
return Inertia::render('Projects/Index', [
'projects' => $projects,
]);
}
Inertia transmet le tableau $projects comme prop à la vue Vue. Pas d'API REST à écrire, pas de fetch à faire côté front — c'est ça la magie d'Inertia.
La vue Index
On crée resources/js/Pages/Projects/Index.vue en s'inspirant du Dashboard du starter kit, et on utilise le composant Card de shadcn-vue (doc) :
<script setup>
import { Head } from '@inertiajs/vue3';
import { create, edit, index } from '@/routes/projects';
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import Button from '@/components/ui/button/Button.vue';
import TextLink from '@/components/TextLink.vue';
defineOptions({
layout: {
breadcrumbs: [
{
title: 'Projets',
href: index(),
},
],
},
});
defineProps({
projects: Array,
});
</script>
<template>
<Head title="Projets" />
<div class="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4">
<div class="flex items-center justify-end">
<TextLink :href="create()">
Créer un projet
</TextLink>
</div>
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
<Card v-for="project in projects" :key="project.id">
<CardHeader>
<CardTitle class="capitalize">{{ project.title }}</CardTitle>
</CardHeader>
<CardContent class="flex-1">
<p>{{ project.description }}</p>
</CardContent>
<CardFooter class="flex justify-end space-x-4">
<TextLink :href="edit(project.id)">
Editer
</TextLink>
<Button variant="destructive" class="ml-2">
Supprimer
</Button>
</CardFooter>
</Card>
</div>
</div>
</template>
Une grille de cartes, chacune avec son titre, sa description, et deux actions. Le lien « Créer un projet » est en haut à droite.

Lien dans la sidebar
Pour atteindre cette page en deux clics, on ajoute une entrée dans la barre latérale du dashboard. Pense aux imports manquants :
import { usePage } from '@inertiajs/vue3';
import { LucideFolder } from 'lucide-vue-next';
import projects from '@/routes/projects';
import type { NavItem } from '@/types';
// pour matcher la page courante et appliquer le style "actif" sur toutes les routes CRUD (index, create, edit)
const page = usePage();
const mainNavItems: NavItem[] = [
{
title: 'Dashboard',
href: dashboard(),
icon: LayoutGrid,
},
{
title: 'Projets',
href: projects.index(),
icon: LucideFolder,
isActive: page.component.startsWith('Projects')
}
];
💡 Toutes les icônes Lucide sont déjà installées dans le starter kit. Catalogue complet sur lucide.dev.
Méthode create et vue Create
Le contrôleur retourne juste la vue, sans données particulières à transmettre :
public function create()
{
return inertia('Projects/Create');
}
Avant d'écrire le formulaire, on installe le composant Textarea de shadcn :
npx shadcn-vue@latest add textarea
Puis on crée resources/js/Pages/Projects/Create.vue en s'inspirant du formulaire de profil fourni par le starter kit :
<script setup>
import { Form, Head } from '@inertiajs/vue3';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import projects from '@/routes/projects';
import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
import Textarea from '@/components/ui/textarea/Textarea.vue';
import Separator from '@/components/ui/separator/Separator.vue';
defineOptions({
layout: {
breadcrumbs: [
{
title: 'Projets',
href: projects.index(),
},
{
title: 'Créer',
href: projects.create(),
},
],
},
});
</script>
<template>
<Head title="Créer un projet" />
<div class="px-4 py-6">
<Heading title="Projet" description="Créer un nouveau projet" />
<div class="flex flex-col lg:flex-row lg:space-x-12">
<Separator class="my-6 lg:hidden" />
<div class="flex-1 md:max-w-2xl">
<section class="max-w-xl space-y-12">
<div class="flex flex-col space-y-6">
<Heading variant="small" title="Informations sur le projet"
description="Mettez à jour le titre et la description du projet" />
<Form v-bind="ProjectController.store.form()" class="space-y-6" v-slot="{ errors, processing }">
<div class="grid gap-2">
<Label for="title">Titre</Label>
<Input id="title" class="mt-1 block w-full" name="title"
placeholder="Titre du projet" />
<InputError class="mt-2" :message="errors.title" />
</div>
<div class="grid gap-2">
<Label for="description">Description</Label>
<Textarea id="description" class="mt-1 block w-full" name="description" rows="10"
placeholder="Description du projet" />
<InputError class="mt-2" :message="errors.description" />
</div>
<div class="flex items-center gap-4">
<Button :disabled="processing">Enregistrer</Button>
</div>
</Form>
</div>
</section>
</div>
</div>
</div>
</template>
Le v-bind="ProjectController.store.form()" câble automatiquement la méthode HTTP, l'URL et le CSRF. Les erreurs de validation arrivent dans le slot errors et s'affichent grâce à <InputError>. Pas une ligne d'axios écrite.
Méthode store
Validation côté serveur, création, redirection, message flash :
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'description' => 'required|string|max:65535',
]);
Project::create($validated);
Inertia::flash('toast', ['type' => 'success', 'message' => __('Projet créé avec succès.')]);
return redirect()->route('projects.index');
}
💡 Le composant
Toastest déjà branché sur cette proptoastdans le layout du starter kit. Tant que tu envoies un objet{ type, message }viaInertia::flash, ça s'affiche automatiquement. Pas de magie noire à comprendre, juste à savoir que c'est là.

Méthode edit et vue Edit
Le contrôleur transmet cette fois le projet à éditer :
public function edit(Project $project)
{
return inertia('Projects/Edit', [
'project' => $project,
]);
}
💡 Laravel utilise l'injection de modèle : tu déclares
Project $projecten paramètre, et il va le chercher automatiquement à partir de l'ID dans l'URL. Si le projet n'existe pas, c'est un 404 direct.
La vue resources/js/Pages/Projects/Edit.vue est quasi identique à Create.vue, avec deux différences : on pré-remplit les champs via :default-value, et on bind ProjectController.update.form({ project: project.id }) pour cibler la bonne route.
<script setup>
import { Form, Head } from '@inertiajs/vue3';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import projects from '@/routes/projects';
import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
import Textarea from '@/components/ui/textarea/Textarea.vue';
import Separator from '@/components/ui/separator/Separator.vue';
defineOptions({
layout: {
breadcrumbs: [
{
title: 'Projets',
href: projects.index(),
},
{
title: 'Éditer',
},
],
},
});
const props = defineProps({
project: Object,
});
</script>
<template>
<Head title="Éditer un projet" />
<div class="px-4 py-6">
<Heading title="Projet" description="Éditer un projet existant" />
<div class="flex flex-col lg:flex-row lg:space-x-12">
<Separator class="my-6 lg:hidden" />
<div class="flex-1 md:max-w-2xl">
<section class="max-w-xl space-y-12">
<div class="flex flex-col space-y-6">
<Heading variant="small" title="Informations sur le projet"
description="Mettez à jour le titre et la description du projet" />
<Form v-bind="ProjectController.update.form({ project: project.id })" class="space-y-6"
v-slot="{ errors, processing }">
<div class="grid gap-2">
<Label for="title">Titre</Label>
<Input id="title" class="mt-1 block w-full" name="title" :default-value="project.title"
placeholder="Titre du projet" />
<InputError class="mt-2" :message="errors.title" />
</div>
<div class="grid gap-2">
<Label for="description">Description</Label>
<Textarea id="description" class="mt-1 block w-full" name="description" rows="10"
placeholder="Description du projet" :default-value="project.description" />
<InputError class="mt-2" :message="errors.description" />
</div>
<div class="flex items-center gap-4">
<Button :disabled="processing">Enregistrer</Button>
</div>
</Form>
</div>
</section>
</div>
</div>
</div>
</template>
Méthode update
Même logique que store, mais on appelle update() sur l'instance existante :
public function update(Request $request, Project $project)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'description' => 'required|string|max:65535',
]);
$project->update($validated);
Inertia::flash('toast', ['type' => 'success', 'message' => __('Projet mis à jour avec succès.')]);
return redirect()->route('projects.index');
}

Méthode destroy
Avant de coder, une règle d'or : jamais de suppression sans confirmation. Cliquer sur « Supprimer » par erreur sur 6 mois de travail, c'est le genre d'expérience qu'on évite à ses utilisateurs.
Côté contrôleur, rien de sorcier :
public function destroy(Project $project)
{
$project->delete();
Inertia::flash('toast', ['type' => 'success', 'message' => __('Projet supprimé avec succès.')]);
return redirect()->route('projects.index');
}
Côté front, on utilise un Dialog shadcn (doc). Le starter kit en contient déjà un exemple dans DeleteUser.vue — calque-toi dessus. On crée donc un composant dédié DeleteProject.vue :
<script setup>
import { Form } from '@inertiajs/vue3';
import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
const props = defineProps({
project: Object,
});
</script>
<template>
<Dialog>
<DialogTrigger as-child>
<Button variant="destructive" class="ml-2">Supprimer</Button>
</DialogTrigger>
<DialogContent>
<Form v-bind="ProjectController.destroy.form({ id: project.id })" :options="{
preserveScroll: true,
}" class="space-y-6" v-slot="{ processing }">
<DialogHeader class="space-y-3">
<DialogTitle>Êtes-vous sûr de vouloir supprimer ce projet ?</DialogTitle>
<DialogDescription>
Cette action est irréversible, toutes les données liées à ce projet seront
définitivement supprimées.
</DialogDescription>
</DialogHeader>
<DialogFooter class="gap-2">
<DialogClose as-child>
<Button variant="secondary">
Annuler
</Button>
</DialogClose>
<Button type="submit" variant="destructive" :disabled="processing">
Supprimer
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
</template>
On remplace ensuite le bouton « Supprimer » provisoire de Index.vue par ce composant, et on en profite pour tronquer les descriptions trop longues :
<script setup>
import { Head } from '@inertiajs/vue3';
import { create, edit, index } from '@/routes/projects';
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import TextLink from '@/components/TextLink.vue';
import DeleteProject from '@/components/DeleteProject.vue';
defineOptions({
layout: {
breadcrumbs: [
{
title: 'Projets',
href: index(),
},
],
},
});
defineProps({
projects: Array,
});
const truncate = (text, length) => {
if (text.length <= length) {
return text;
}
return text.substring(0, length) + '...';
};
</script>
<template>
<Head title="Projets" />
<div class="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4">
<div class="flex items-center justify-end">
<TextLink :href="create()">
Créer un projet
</TextLink>
</div>
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
<Card v-for="project in projects" :key="project.id">
<CardHeader>
<CardTitle class="capitalize">{{ project.title }}</CardTitle>
</CardHeader>
<CardContent class="flex-1">
<p>{{ truncate(project.description, 100) }}</p>
</CardContent>
<CardFooter class="flex justify-end space-x-4">
<TextLink :href="edit(project.id)">
Editer
</TextLink>
<DeleteProject :project="project" />
</CardFooter>
</Card>
</div>
</div>
</template>

💡 On n'a pas implémenté la méthode
show()du contrôleur : la route existe (créée parRoute::resource), mais on s'en servira plus tard pour la page de détail publique d'un projet.
La homepage publique
L'admin est fini. Place au frontoffice — la partie que verront tes visiteurs. On veut une page d'accueil qui liste les projets, accessible sans connexion.
On génère un contrôleur dédié dans un sous-namespace Front pour bien séparer ce qui est public de ce qui est admin :
php artisan make:controller Front/HomepageController
Dans routes/web.php, on remplace la route d'accueil par défaut :
Route::get('/', HomepageController::class)->name('home');
Pas de méthode dans l'URL ? C'est parce que HomepageController est invokable — il n'a qu'une seule méthode publique, __invoke(), qu'on peut donc invoquer directement :
class HomepageController extends Controller
{
public function __invoke()
{
$projects = Project::all();
return inertia('Homepage', [
'projects' => $projects,
]);
}
}
💡 Les contrôleurs invokables sont parfaits pour les actions uniques (une page statique, un endpoint isolé). Au-delà d'une méthode, repasse sur un contrôleur classique.
Un layout pour le frontoffice
L'admin utilise AppLayout (sidebar, breadcrumbs, etc.). Pour le public, on veut une mise en page différente — barre de navigation simple, footer. On crée resources/js/layouts/FrontLayout.vue :
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
const { title = '', description = '' } = defineProps<{
title?: string;
description?: string;
}>();
</script>
<template>
<div class="min-h-screen flex flex-col bg-gray-100">
<nav class="flex items-center justify-between p-4 bg-lime-200 border-b border-lime-300">
<Link href="/" class="text-xl font-bold">
Portfolio
</Link>
<div class="space-x-4">
<Link href="/">Accueil</Link>
<Link href="#">Projets</Link>
<Link href="#">Contact</Link>
</div>
</nav>
<div class="pt-4 pb-8 px-4">
<h1 class="text-2xl font-bold p-4">{{ title }}</h1>
<p class="text-gray-600 px-4">{{ description }}</p>
</div>
<slot />
<footer class="p-4 bg-gray-100 flex items-center justify-center mt-auto">
<p>© {{ new Date().getFullYear() }} Portfolio. Tous droits réservés.</p>
</footer>
</div>
</template>
⚠️ Ce design est délibérément moche. C'est la base à laquelle l'énoncé te demande de mettre ta patte créative — tu n'es pas censé livrer ça en l'état.
Router les pages vers le bon layout
On veut que les pages dans Pages/Front/ utilisent FrontLayout, les autres restent sur AppLayout ou AuthLayout. Le second argument de createInertiaApp.layout reçoit le nom complet du composant (ex. Front/Homepage, auth/Login), donc un switch suffit :
// ...
createInertiaApp({
title: (title) => (title ? `${title} - ${appName}` : appName),
layout: (name) => {
switch (true) {
case name.startsWith('Front/'):
return FrontLayout;
case name.startsWith('auth/'):
return AuthLayout;
case name.startsWith('settings/'):
return [AppLayout, SettingsLayout];
default:
return AppLayout;
}
},
// ... autres options
La vue Homepage
Enfin, resources/js/Pages/Front/Homepage.vue affiche les projets :
<script setup>
import { Link } from '@inertiajs/vue3';
defineProps({
projects: Array,
});
defineOptions({
layout: {
title: 'Accueil',
description: 'Bienvenue sur mon portfolio ! Découvrez mes projets et réalisations.',
},
});
const truncate = (text, length) => {
if (text.length <= length) {
return text;
}
return text.substring(0, length) + '...';
};
</script>
<template>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 m-4 p-4 rounded-lg bg-white shadow">
<Link href="#" v-for="project in projects" :key="project.id" class="border rounded p-4 shadow bg-gray-50">
<h2 class="text-xl font-bold mb-2 capitalize">{{ project.title }}</h2>
<p class="text-gray-600">{{ truncate(project.description, 100) }}</p>
</Link>
</div>
</template>

Récap
À ce stade tu as :
- ✅ Un modèle
Projectcomplet (migration, factory, seeder) - ✅ Un CRUD admin protégé par authentification
- ✅ Des formulaires Inertia avec validation serveur et messages flash
- ✅ Une confirmation de suppression via Dialog
- ✅ Une homepage publique avec son layout dédié
Le squelette est posé. Tout ce qui reste à faire dans l'énoncé suit le même schéma.
Et après ?
L'énoncé liste plusieurs entités à gérer en plus. Trois prochaines étapes recommandées dans l'ordre :
- Les technologies — refais le même CRUD que pour les projets, puis branche la relation many-to-many entre
ProjectetTechnology. - L'upload d'images (image principale + galerie) pour les projets. Garde les
5MBmax et la validation de format en tête. - La page de détail publique d'un projet (
Front/ProjectDetail.vue) — c'est là qu'on implémentera enfin la méthodeshow()du contrôleur.
💡 Bonne pratique : commit après chaque feature qui marche. Un projet sans historique git, c'est un projet sans filet de sécurité.
