Créer un projet : le formulaire pas à pas
Temps 1 · séance 3 · ~80 min
- Écrire un formulaire avec le composant
<Form>d'Inertia et une fonction de Wayfinder. - Valider les champs dans une Form Request, avec des messages en français.
- Raconter le trajet d'un envoi, du clic jusqu'au message de succès.
- Générer le slug à partir du titre et enregistrer les étiquettes cochées.
Le code qui sent mauvais
En binôme, cinq minutes. Cette méthode enregistre un nouveau projet envoyé par le formulaire du back-office.
public function store(Request $request): RedirectResponse
{
Project::create($request->all());
return to_route('admin.projects.index');
}
Le formulaire contient quatre champs, titre, résumé, lien de la démo et lien du code, puis les étiquettes à cocher. Qu'est-ce qui peut arriver dans la table projects en plus de ces quatre valeurs, ou à leur place ? Indice : relisez l'attribut #[Fillable] de votre modèle Project, écrit au chapitre 1.06.
1. La Form Request
Vous connaissez les Form Requests depuis le chapitre 16 de 5XCOS : une classe qui contient les règles d'un formulaire, et que Laravel applique avant d'appeler le contrôleur. Le formulaire de création et celui de modification du chapitre 1.13 partagent les mêmes règles. Elles s'écrivent donc une fois, dans une seule classe. Créez-la :
php artisan make:request ProjectRequest
INFO Request [app/Http/Requests/ProjectRequest.php] created successfully.
Le fichier généré contient une méthode authorize() qui renvoie false. Avec elle, chaque envoi du formulaire reçoit une erreur 403, « interdit ». Supprimez la méthode entière. Les Form Requests du kit, dans app/Http/Requests/Settings/, n'en ont pas, et sans elle Laravel autorise la requête. Votre back-office est déjà protégé par le middleware auth du chapitre 1.10.
Remplacez le contenu de la classe :
class ProjectRequest extends FormRequest
{
protected function prepareForValidation(): void
{
if ($this->isMethod('POST')) {
$this->merge([
'slug' => Str::slug((string) $this->input('title')),
]);
}
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'slug' => $this->isMethod('POST') ? [Rule::unique('projects', 'slug')] : ['prohibited'],
'summary' => ['required', 'string', 'max:500'],
'content' => ['nullable', 'string'],
'demo_url' => ['nullable', 'url', 'max:255'],
'repo_url' => ['nullable', 'url', 'max:255'],
'tags' => ['array'],
'tags.*' => ['integer', 'exists:tags,id'],
];
}
public function messages(): array
{
return ['slug.unique' => 'Un projet porte déjà ce titre.'];
}
public function attributes(): array
{
return [
'summary' => 'résumé',
'demo_url' => 'lien de la démo',
'repo_url' => 'lien du code',
];
}
}
Ajoutez use Illuminate\Support\Str; et use Illuminate\Validation\Rule; en tête du fichier.
Les règles reprennent les colonnes de la migration du chapitre 1.06. max:500 sur summary correspond à string('summary', 500). content reste nullable : le formulaire de ce chapitre ne le contient pas, et l'éditeur de texte riche le remplira au chapitre 1.15. Les deux liens sont facultatifs. S'ils sont remplis, la règle url vérifie qu'ils ont la forme d'une adresse complète, comme https://exemple.be. Les étiquettes arrivent sous forme de tableau, et chacune doit exister dans la table tags.
Les trois autres méthodes, prepareForValidation(), messages() et attributes(), reviennent aux sections 2 et 6.
2. Les messages en français
Avec les réglages du kit, les erreurs de validation sont en anglais, par exemple « The title field is required. ». Vous avez réglé ce point en 5XCOS avec les traductions de Laravel-Lang. Faites de même dans le portfolio :
composer require laravel-lang/common --dev
php artisan lang:add fr
Using version ^6.8 for laravel-lang/common
…
fr/validation.php .............................................. 4.14ms DONE
…
Dans .env, passez la langue de l'application en français, puis relancez composer run dev :
APP_LOCALE=fr
Les messages passent en français, mais les noms de champs anglais restent. Un résumé vide donne « Le champ summary est obligatoire. ». Les fichiers de traduction connaissent title, traduit « titre », mais pas summary ni demo_url.
La méthode attributes() de la Form Request donne le nom français de chaque champ. Avec elle, un formulaire vide et un lien mal formé donnent ces trois messages :
Le champ titre est obligatoire.
Le champ résumé est obligatoire.
Le format de l'URL de lien de la démo n'est pas valide.
Le nom de chaque champ est écrit une fois, dans la Form Request, et sert à tous ses messages.
3. Le contrôleur
Admin\ProjectController reçoit deux méthodes. create() affiche le formulaire, store() reçoit son envoi. Ajoutez-les sous index(), avec use App\Http\Requests\ProjectRequest;, use App\Models\Tag; et use Illuminate\Http\RedirectResponse; en tête :
public function create(): Response
{
return Inertia::render('admin/projects/Create', [
'tags' => Tag::orderBy('name')->get(['id', 'name']),
]);
}
public function store(ProjectRequest $request): RedirectResponse
{
$project = Project::create([
...$request->safe()->except('tags'),
'position' => Project::max('position') + 1,
]);
$project->tags()->sync($request->validated('tags', []));
Inertia::flash('toast', ['type' => 'success', 'message' => 'Projet créé.']);
return to_route('admin.projects.index');
}
create() envoie à la page la liste des étiquettes, pour en faire des cases à cocher. Chaque étiquette n'a que deux clés, id et name.
store() reçoit une ProjectRequest au lieu d'une Request. Quand la méthode s'exécute, la validation est déjà passée.
$request->safe()->except('tags')donne les champs validés, sans les étiquettes. Le slug calculé en fait partie, puisqu'il a ses règles.Project::max('position') + 1place le nouveau projet après les autres. Le visiteur ne choisit jamais cette valeur.sync()enregistre les étiquettes dans la tableproject_tag, commeallergens()->sync()à la friterie. La section 7 y revient.Inertia::flash()prépare un message pour la page suivante, et la section 5 montre comment il s'affiche.to_route('admin.projects.index')redirige vers la liste.
4. La page Create.vue
Créez resources/js/pages/admin/projects/Create.vue :
<script setup>
import { Form, Head } from '@inertiajs/vue3';
import ProjectController from '@/actions/App/Http/Controllers/Admin/ProjectController';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import { Textarea } from '@/components/ui/textarea';
import { create, index } from '@/routes/admin/projects';
defineOptions({
layout: {
breadcrumbs: [
{ title: 'Projets', href: index() },
{ title: 'Nouveau projet', href: create() },
],
},
});
defineProps({
tags: Array,
});
</script>
<template>
<Head title="Nouveau projet" />
<div class="max-w-2xl p-4">
<h1 class="mb-6 text-xl font-semibold">Nouveau projet</h1>
<Form
v-bind="ProjectController.store.form()"
v-slot="{ errors, processing }"
class="space-y-6"
novalidate
>
<div class="grid gap-2">
<Label for="title">Titre</Label>
<Input id="title" name="title" />
<InputError :message="errors.title || errors.slug" />
</div>
<div class="grid gap-2">
<Label for="summary">Résumé</Label>
<Textarea id="summary" name="summary" rows="3" />
<InputError :message="errors.summary" />
</div>
<div class="grid gap-2">
<Label for="demo_url">Lien de la démo</Label>
<Input id="demo_url" name="demo_url" type="url" />
<InputError :message="errors.demo_url" />
</div>
<div class="grid gap-2">
<Label for="repo_url">Lien du code</Label>
<Input id="repo_url" name="repo_url" type="url" />
<InputError :message="errors.repo_url" />
</div>
<fieldset class="grid gap-2">
<legend class="mb-2 text-sm font-medium">Technologies</legend>
<Label
v-for="tag in tags"
:key="tag.id"
class="flex items-center gap-3"
>
<Checkbox name="tags[]" :value="String(tag.id)" />
<span>{{ tag.name }}</span>
</Label>
<InputError :message="errors.tags" />
</fieldset>
<Button type="submit" :disabled="processing">
<Spinner v-if="processing" />
Créer le projet
</Button>
</Form>
</div>
</template>
La page suit la forme de la page de connexion du kit, resources/js/pages/auth/Login.vue. Ouvrez-la à côté : vous y retrouvez <Form>, InputError, Spinner et le bouton désactivé.
<Form> et la fonction de Wayfinder
<Form> est un composant d'Inertia, importé de @inertiajs/vue3 comme Link et Head. Il produit une balise <form>, intercepte son envoi et l'envoie lui-même, sans recharger la page. Il a besoin de deux renseignements : l'adresse où envoyer, et la méthode HTTP.
Wayfinder les fournit. Au chapitre 1.08, vous avez importé des fonctions par nom de route, comme show depuis @/routes/projects. Pour un formulaire, le kit importe plutôt le contrôleur entier depuis @/actions/, comme ProfileController dans sa page de profil. ProjectController.store est la fonction de la méthode store() de votre contrôleur. Sa variante .form() est générée dans resources/js/actions/App/Http/Controllers/Admin/ProjectController.ts :
const storeForm = (options?: RouteQueryOptions): RouteFormDefinition<'post'> => ({
action: store.url(options),
method: 'post',
})
ProjectController.store.form() renvoie donc l'objet { action: '/admin/projets', method: 'post' }. v-bind suivi d'un objet, sans nom d'attribut, pose chaque clé de l'objet comme un attribut du composant. <Form v-bind="ProjectController.store.form()"> revient à écrire <Form action="/admin/projets" method="post">, sans adresse en dur.
novalidate : une seule validation, celle du serveur
Les champs des deux liens sont en type="url". Sans autre réglage, le navigateur vérifie lui-même ces champs avant l'envoi : tapez exemple.be, et Chrome affiche sa propre bulle, « Veuillez saisir une URL. », sans rien envoyer. La Form Request ne reçoit jamais la requête, et son message en français ne s'affiche pas.
L'attribut novalidate, posé sur <Form>, désactive cette vérification du navigateur. Le formulaire part toujours, et tous les messages viennent du même endroit : la Form Request. Gardez type="url" : sur un téléphone, il affiche le clavier des adresses.
Des champs sans v-model
Les champs n'ont pas de v-model, seulement un attribut name. Au moment de l'envoi, <Form> lit les valeurs de tous les champs du formulaire par leur name, comme le ferait un formulaire HTML classique. Un formulaire dont Vue ne suit pas les valeurs s'appelle un formulaire non contrôlé. Vous n'écrivez aucune variable pour les champs.
Input, Textarea et Label sont les composants shadcn-vue du chapitre 1.11. Label for="title" relie l'étiquette au champ id="title", comme en HTML : un clic sur l'étiquette place le curseur dans le champ.
Le slot : errors et processing
v-slot="{ errors, processing }" récupère deux valeurs que <Form> met à jour pendant l'envoi.
errorsest un objet qui contient un message par champ refusé, sous le nom du champ :errors.title,errors.summary. Il est vide tant que rien n'a été refusé.processingvauttruedu clic jusqu'à la réponse du serveur, puisfalse.
InputError est un composant du kit, dans resources/js/components/InputError.vue. Il affiche le message reçu en rouge, et rien quand le message est vide. errors.title || errors.slug affiche sous le titre l'erreur du titre, ou à défaut celle du slug.
:disabled="processing" désactive le bouton pendant l'envoi, et <Spinner v-if="processing" /> affiche une petite roue qui tourne. Un double clic n'envoie donc pas deux fois le même projet.

5. Le trajet d'un envoi
Ouvrez l'onglet Réseau, puis cliquez sur « Créer le projet » avec le formulaire vide. Voici ce qui se passe, dans l'ordre.
<Form>rassemble les champs. Il lit chaque champ par sonname, et construit les données à envoyer :title,summary,demo_url,repo_urlet les étiquettes cochées.processingpasse àtrue. Le bouton se désactive et la roue tourne.- Inertia envoie la requête. L'onglet Réseau montre une requête
POSTversadmin/projets, avec l'en-têteX-Inertia: true, comme les requêtes de<Link>au chapitre 1.08. - Laravel trouve la route
admin.projects.store, et la requête passe par les middlewaresauthetverified. - Laravel prépare la Form Request. Il appelle
prepareForValidation(), qui calcule le slug, puis il appliquerules(). - La validation échoue. Laravel n'appelle pas
store(). Il range les erreurs dans la session et répond par une redirection vers la page précédente.
Voici les réponses du serveur aux deux envois, le premier vide et le second valide, coupées à l'en-tête utile :
--- POST invalide
HTTP/1.1 302 Found
Location: http://…/admin/projets/nouveau
--- POST valide
HTTP/1.1 302 Found
Location: http://…/admin/projets
Dans les deux cas, le serveur répond par une redirection 302. Un envoi refusé ne reçoit pas de code 422 : ce code n'existe que pour une requête qui demande du JSON, comme une API. Seule l'adresse de la redirection change.
- Inertia suit la redirection. Il demande la page du formulaire, que Laravel renvoie avec les erreurs dans la prop
errors. Inertia garde le même composant de page et met seulement à jour ses props. errorsse remplit etprocessingrepasse àfalse. Sous chaque champ refusé,InputErroraffiche son message : « Le champ titre est obligatoire. », « Le champ résumé est obligatoire. ». Le bouton se réactive.
Les valeurs tapées restent dans les champs, car la page n'a pas été reconstruite. Vous corrigez le champ fautif et vous renvoyez, sans tout retaper.

Remplissez maintenant le titre et le résumé, cochez une étiquette, puis renvoyez. Les étapes 1 à 5 se répètent.
- La validation réussit. Laravel appelle
store(), qui crée le projet, enregistre ses étiquettes et prépare le message flash. - Inertia suit la redirection vers la liste. La réponse contient, à côté des props, une clé
flashqui vaut{"toast":{"type":"success","message":"Projet créé."}}. - Le kit affiche une notification. Le fichier
resources/js/lib/flashToast.tsécoute les messages flash :
export function initializeFlashToast(): void {
router.on('flash', (event) => {
const flash = (event as CustomEvent).detail?.flash;
const data = flash?.toast as FlashToast | undefined;
if (!data) {
return;
}
toast[data.type](data.message);
});
}
router.on('flash', …) appelle la fonction chaque fois qu'une réponse apporte un message flash. Si le message porte la clé toast, la fonction appelle toast.success('Projet créé.'), du paquet vue-sonner. Le <Toaster /> du layout du back-office, vu au chapitre 1.11, affiche la notification dans un coin de l'écran. Les autres types prévus par le kit sont info, warning et error.

En 5XCOS, un message flash s'écrivait avec ->with('status', …) et se lisait dans le template Blade. Inertia::flash() joue le même rôle : le message vaut pour la page suivante seulement, et un rechargement le fait disparaître.
Voir processing à l'œil nu
Sur votre machine, l'aller-retour dure une fraction de seconde, et la roue n'a pas le temps d'apparaître. Pour la voir, ajoutez temporairement cette ligne au début de store() :
usleep(2000000);
usleep() met PHP en pause, ici deux millions de microsecondes, soit deux secondes. Envoyez un formulaire valide : le bouton reste grisé avec sa roue pendant deux secondes. Avec un formulaire vide, la pause ne joue pas, puisque la validation répond avant que store() ne soit appelé. Retirez la ligne ensuite.
6. Le slug, calculé à partir du titre
Le formulaire n'a pas de champ « slug ». L'adresse publique d'un projet doit pourtant en avoir un, unique. La Form Request le calcule à partir du titre, dans prepareForValidation(), que Laravel appelle avant de valider :
protected function prepareForValidation(): void
{
if ($this->isMethod('POST')) {
$this->merge([
'slug' => Str::slug((string) $this->input('title')),
]);
}
}
$this->merge() ajoute une valeur à la requête, comme si le formulaire l'avait envoyée. Str::slug() transforme un titre en adresse. Testez-le dans Tinker :
php artisan tinker --execute="echo Illuminate\Support\Str::slug('Météo Wavre');"
meteo-wavre
Les accents disparaissent, les espaces deviennent des tirets, les majuscules passent en minuscules. (string) transforme un titre absent en chaîne vide, que Str::slug() accepte.
$this->isMethod('POST') limite le calcul à la création : le formulaire de ce chapitre envoie en POST. Le formulaire de modification du chapitre 1.13 enverra en PUT, et la Form Request n'y calculera rien. Un projet garde ainsi l'adresse reçue à sa création, même si vous corrigez son titre plus tard. Un lien partagé vers sa fiche reste bon.
La règle du slug suit la même condition. À la création, Rule::unique('projects', 'slug') refuse un slug déjà présent dans la table projects : deux projets nommés « Météo Wavre » auraient la même adresse, et le second est refusé. messages() remplace le message de Laravel par « Un projet porte déjà ce titre. ». Le formulaire n'a pas de champ slug, d'où errors.title || errors.slug sous le titre. À la modification, la règle devient prohibited : la requête ne doit pas contenir de slug. Le formulaire n'en envoie pas, et une requête fabriquée qui en glisserait un est refusée.
Le slug n'a pas la règle required. Avec elle, un titre vide donnerait deux erreurs, une pour le titre et une pour le slug, alors qu'une seule suffit.
7. Les étiquettes cochées
Chaque étiquette devient une case à cocher :
<Checkbox name="tags[]" :value="String(tag.id)" />
Le composant Checkbox de shadcn-vue dessine sa propre case, et il ajoute une vraie case HTML, cachée, qui porte le name et la valeur. <Form> lit cette case cachée comme n'importe quel champ. Les crochets de tags[] disent à Laravel de rassembler toutes les cases cochées dans un seul tableau. Cochez « Vue.js » et « JavaScript », et la requête contient tags avec deux numéros.
String(tag.id) donne la valeur sous forme de texte, comme dans un formulaire HTML. La règle tags.* vérifie chaque élément du tableau : un entier, présent dans la colonne id de la table tags. Laravel accepte comme entier un texte qui ne contient que des chiffres.
Dans store(), sync() rend les lignes de project_tag identiques à la liste reçue :
$project->tags()->sync($request->validated('tags', []));
validated('tags', []) renvoie le tableau validé, ou un tableau vide si aucune case n'est cochée. Une case non cochée n'envoie rien du tout, et la clé tags est alors absente de la requête. Vous connaissez sync() depuis la friterie de 5XCOS. Au chapitre 1.13, il retirera aussi les étiquettes décochées.
Vérifiez dans Tinker les étiquettes d'un projet, ici celles du quiz :
php artisan tinker --execute="echo App\Models\Project::firstWhere('slug', 'quiz-des-capitales')->tags->pluck('name');"
["Vue.js","JavaScript"]
Remplacez le slug par celui du projet que vous venez de créer.
8. Pratique : créer un vrai projet (35 min)
Recopiez le formulaire en suivant chaque section. Ne collez pas le code d'un bloc : tapez-le, et relisez l'explication qui le suit.
ProjectRequestsansauthorize(), avec ses règles,prepareForValidation(),messages()etattributes().- Laravel-Lang installé,
lang:add fr, puisAPP_LOCALE=frdans.env. create()etstore()dansAdmin\ProjectController.- La page
admin/projects/Create.vue.
Vérifiez dans le navigateur.
- Le bouton « Nouveau projet » de la liste ouvre le formulaire, avec le fil d'Ariane « Projets › Nouveau projet ».
- Envoyé vide, le formulaire affiche un message en français sous le titre et sous le résumé, et aucun projet n'est créé.
- Un lien de démo sans
https://affiche « Le format de l'URL de lien de la démo n'est pas valide. », et les autres champs gardent leur valeur. - Un titre déjà utilisé affiche « Un projet porte déjà ce titre. » sous le titre.
- Un envoi valide ramène à la liste, avec le nouveau projet en dernière ligne et la notification « Projet créé. ».
Puis créez un vrai projet : un quatrième projet de votre parcours, avec son titre, son résumé, ses liens et ses étiquettes. Vérifiez ses étiquettes avec la commande Tinker de la section 7, et ouvrez sa fiche publique à l'adresse /projets/ suivie de son slug. Sa fiche n'a pas encore d'image ni de contenu : ils arrivent aux chapitres 1.14 et 1.15.
Ce que votre portfolio contient maintenant
Un formulaire de création dans le back-office. Il valide chaque champ côté serveur, affiche les erreurs en français sous les champs, calcule le slug et enregistre les étiquettes. Un projet créé apparaît dans la liste avec une notification, et sa fiche publique existe aussitôt.
À retenir
<Form v-bind="ProjectController.store.form()">reçoit l'adresse et la méthode de Wayfinder, et envoie le formulaire sans recharger la page.- Les champs n'ont pas de
v-model:<Form>lit chaque valeur par son attributname. - Une Form Request applique ses règles avant le contrôleur, et
attributes()donne un nom français à chaque champ dans les messages. - Un envoi refusé répond par une redirection 302, et les messages arrivent dans
errorspendant queprocessingdésactive le bouton. Inertia::flash('toast', [...])prépare un message que le kit affiche en notification sur la page suivante.