Aller au contenu

Créer un projet : le formulaire pas à pas

Temps 1 · séance 3 · ~80 min

Les slides du chapitre (PDF)

  • É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.

  1. $request->safe()->except('tags') donne les champs validés, sans les étiquettes. Le slug calculé en fait partie, puisqu'il a ses règles.
  2. Project::max('position') + 1 place le nouveau projet après les autres. Le visiteur ne choisit jamais cette valeur.
  3. sync() enregistre les étiquettes dans la table project_tag, comme allergens()->sync() à la friterie. La section 7 y revient.
  4. Inertia::flash() prépare un message pour la page suivante, et la section 5 montre comment il s'affiche.
  5. 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.

Wayfinder lit la méthode store() du contrôleur et génère ProjectController.store.form(), qui renvoie l'objet { action: '/admin/projets', method: 'post' } ; v-bind pose ses deux clés sur Form. Sur ce même Form, novalidate laisse une seule validation, celle du serveur, et les champs sans v-model, comme name="title", font un formulaire non contrôlé

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.

  • errors est 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é.
  • processing vaut true du clic jusqu'à la réponse du serveur, puis false.

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.

Le bas du formulaire Nouveau projet pendant l'envoi : les cases Technologies, puis le bouton Créer le projet grisé, avec une petite roue qui tourne à gauche de son texte

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.

  1. <Form> rassemble les champs. Il lit chaque champ par son name, et construit les données à envoyer : title, summary, demo_url, repo_url et les étiquettes cochées.
  2. processing passe à true. Le bouton se désactive et la roue tourne.
  3. Inertia envoie la requête. L'onglet Réseau montre une requête POST vers admin/projets, avec l'en-tête X-Inertia: true, comme les requêtes de <Link> au chapitre 1.08.
  4. Laravel trouve la route admin.projects.store, et la requête passe par les middlewares auth et verified.
  5. Laravel prépare la Form Request. Il appelle prepareForValidation(), qui calcule le slug, puis il applique rules().
  6. 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.

  1. 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.
  2. errors se remplit et processing repasse à false. Sous chaque champ refusé, InputError affiche 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.

Le formulaire Nouveau projet après un envoi refusé : le titre vide avec Le champ titre est obligatoire en rouge, le résumé vide avec Le champ résumé est obligatoire, le lien de la démo rempli avec exemple.be et Le format de l'URL de lien de la démo n'est pas valide

Remplissez maintenant le titre et le résumé, cochez une étiquette, puis renvoyez. Les étapes 1 à 5 se répètent.

  1. La validation réussit. Laravel appelle store(), qui crée le projet, enregistre ses étiquettes et prépare le message flash.
  2. Inertia suit la redirection vers la liste. La réponse contient, à côté des props, une clé flash qui vaut {"toast":{"type":"success","message":"Projet créé."}}.
  3. 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.

La liste des projets après la création : le nouveau projet en dernière ligne du tableau et la notification Projet créé dans le coin inférieur droit

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.

Le même POST /admin/projets avec X-Inertia: true, deux fois, à travers auth, verified et la Form Request. Envoi vide : rules() échoue, store() n'est pas appelé, 302 vers …/admin/projets/nouveau, errors se remplit et processing repasse à false. Envoi valide : store() appelle sync() et Inertia::flash(), 302 vers …/admin/projets, et flash déclenche toast.success('Projet créé.')

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 titre Météo Wavre passe par Str::slug() et devient meteo-wavre, que merge() ajoute à la requête comme slug avant rules(). En POST, la règle est Rule::unique('projects', 'slug') ; en PUT, au chapitre 1.13, elle devient prohibited

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.

Chaque étiquette est une case Checkbox de valeur String(tag.id) : Vue.js et JavaScript cochées arrivent dans la requête sous la clé tags, deux numéros ; la règle tags. vérifie chaque élément, integer et exists:tags,id ; puis sync() écrit les lignes de project_tag

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.

  1. ProjectRequest sans authorize(), avec ses règles, prepareForValidation(), messages() et attributes().
  2. Laravel-Lang installé, lang:add fr, puis APP_LOCALE=fr dans .env.
  3. create() et store() dans Admin\ProjectController.
  4. 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 attribut name.
  • 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 errors pendant que processing désactive le bouton.
  • Inertia::flash('toast', [...]) prépare un message que le kit affiche en notification sur la page suivante.