Aller au contenu

Formulaires : créer et valider

Bloc Laravel · séance 8 · ~60 min

Slides du chapitre (PDF)PDF

Jusqu'ici, les données de vos sites venaient d'un seeder ou d'une route bac à sable. Dans ce chapitre, Nadia, qui tient une friterie à Wavre, ajoute elle-même un produit à sa carte depuis un formulaire. Vous écrivez ce formulaire, puis vous vérifiez chaque champ avant de l'enregistrer.

À la fin de ce chapitre, vous serez capables de :

  1. écrire un formulaire d'ajout et la route POST qui le reçoit,
  2. valider les champs reçus, et lire ce que Laravel fait quand une règle échoue,
  3. afficher les erreurs et les anciennes valeurs dans le formulaire,
  4. traduire les messages en français,
  5. confirmer l'enregistrement par un message flash.

1. La friterie

Installer le dépôt (10 minutes, une seule fois)

Sur github.com/opmvpc/friterie-26, créez votre copie avec le bouton Fork. Dans un terminal ouvert dans le dossier de Herd ou de Laragon, préparez le projet comme au chapitre 14 :

git clone https://github.com/VOTRE-COMPTE/friterie-26.git
cd friterie-26
composer install
cp .env.example .env
php artisan key:generate
php artisan migrate --seed
npm install

Si migrate propose de créer database/database.sqlite, appuyez sur Entrée. La base reçoit la carte de Nadia : quatre catégories, huit allergènes et seize produits. Lancez ensuite cette commande, et laissez son terminal ouvert :

composer run dev

Ouvrez http://friterie-26.test. Avec Laragon, rechargez d'abord Laragon depuis son menu.

Les pages

Adresse Vue Ce qu'elle affiche
/carte products/index les produits par catégorie, avec prix, allergènes et badge « En rupture »
/produits/{id} products/show la fiche d'un produit
/categories/{id} categories/show une catégorie et ses produits
/composants styleguide le guide de style, comme au chapitre 14

Le site n'a pas de comptes utilisateurs : l'authentification est au programme du cours « Projet Web Dynamique ».

Ces pages sont déjà écrites, avec ce que vous connaissez des chapitres 7 à 12. Avant de continuer, lisez app/Http/Controllers/ProductController.php, puis les modèles Category, Product et Allergen, dans app/Models/. Repérez dans Product la liste #[Fillable] et la méthode isSoldOut() : ce chapitre et le suivant s'en servent. Voici routes/web.php, sous ses trois lignes use :

Route::redirect('/', '/carte');

Route::get('/carte', [ProductController::class, 'index'])->name('products.index');
Route::get('/produits/{id}', [ProductController::class, 'show'])->whereNumber('id')->name('products.show');

Route::get('/categories/{id}', [CategoryController::class, 'show'])->whereNumber('id')->name('categories.show');

Route::view('/composants', 'styleguide')->name('styleguide');

Quatre composants servent aux formulaires : <x-label>, <x-input>, <x-select> et <x-textarea>. Ce sont ceux imaginés à la fin du chapitre 14 : chacun écrit une seule fois les classes de l'exercice 14.7, et /composants les montre normaux puis en erreur. Ils diffèrent du corrigé du chapitre 14 sur deux points : chacun lit lui-même les erreurs, sans prop invalid, et porte sa marge mt-2.

Le code qui sent mauvais

En binôme, cinq minutes. Un collègue de Nadia veut retirer un produit d'un simple clic. Il ajoute cette route, et un lien « Retirer » sur chaque fiche :

Route::get('/produits/{id}/retirer', fn (int $id) => Product::findOrFail($id)->delete());

Le lien fonctionne : /produits/16/retirer affiche 1, la valeur renvoyée par delete(), et le café a disparu de la carte. Qu'arrive-t-il la première fois qu'un moteur de recherche, ou le préchargement des liens du navigateur, suit ce lien ?

2. Le formulaire d'ajout

Nadia veut ajouter la viandelle à sa carte. Il lui faut deux routes : l'une affiche le formulaire, l'autre reçoit ce qu'il envoie. Ajoutez-les sous la route /carte :

Route::get('/produits/nouveau', [ProductController::class, 'create'])->name('products.create');
Route::post('/produits', [ProductController::class, 'store'])->name('products.store');

Ici, whereNumber('id') protège déjà la route /produits/{id}, car nouveau n'est pas un nombre. Déclarez quand même /produits/nouveau avant /produits/{id}, comme le demande le piège de l'ordre du chapitre 7 : sans whereNumber, l'ordre serait la seule protection.

Dans ProductController, la méthode create() passe les catégories à la vue, dans l'ordre de la carte :

public function create(): View
{
    $categories = Category::orderBy('position')->get();

    return view('products.create', ['categories' => $categories]);
}
php artisan make:view products.create
   INFO  View [resources/views/products/create.blade.php] created successfully.

Chez vous, Artisan affiche le chemin complet du fichier, depuis la racine du disque. Le fichier ne contient qu'un <div> et une citation. Remplacez tout son contenu par le formulaire :

<x-layouts.app title="Ajouter un produit">
    <div class="mx-auto max-w-2xl">
        <h1 class="mb-8 text-3xl font-semibold tracking-tight">Ajouter un produit</h1>

        <form method="POST" action="{{ route('products.store') }}" class="space-y-6 rounded-xl border border-zinc-200 bg-white p-6 shadow-xs sm:p-8 dark:border-zinc-800 dark:bg-zinc-900">
            @csrf

            <div>
                <x-label for="name">Nom</x-label>
                <x-input name="name" />
            </div>

            <div>
                <x-label for="category_id">Catégorie</x-label>
                <x-select name="category_id">
                    <option value="">Choisissez une catégorie</option>
                    @foreach ($categories as $category)
                        <option value="{{ $category->id }}">{{ $category->name }}</option>
                    @endforeach
                </x-select>
            </div>

            <div>
                <x-label for="price">Prix (€)</x-label>
                <x-input name="price" type="number" step="0.10" min="0" />
            </div>

            <div>
                <x-label for="description">Description</x-label>
                <x-textarea name="description"></x-textarea>
            </div>

            <div class="flex justify-end">
                <x-button type="submit">Ajouter le produit</x-button>
            </div>
        </form>
    </div>
</x-layouts.app>

Chaque <option> envoie le numéro de la catégorie, que la colonne category_id attend, et non son nom. step="0.10" fait avancer les flèches du prix de dix centimes. Le navigateur refuse alors un prix comme 2.85 et propose « 2,8 » ou « 2,9 » : écrivez step="0.01" si la carte a besoin des centimes. Sans type="submit", le composant button écrit type="button", et le clic n'envoie rien. @csrf ajoute le jeton du chapitre 9.

php artisan route:list --path=produits
  POST       produits ................................... products.store › ProductController@store
  GET|HEAD   produits/nouveau ......................... products.create › ProductController@create
  GET|HEAD   produits/{id} ................................ products.show › ProductController@show

                                                                                Showing [3] routes

Enregistrer le produit

Au chapitre 9, input() lisait un champ de la requête. La méthode only() renvoie un tableau des seuls champs cités. Importez RedirectResponse et Request sous Product, puis ajoutez store() sous create() :

use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
public function store(Request $request): RedirectResponse
{
    $product = Product::create($request->only(['name', 'category_id', 'price', 'description']));

    return redirect()->route('products.show', $product->id);
}

create() remplit les colonnes de #[Fillable], comme au chapitre 10, et la redirection du chapitre 7 ouvre la fiche du nouveau produit. Sur http://friterie-26.test/produits/nouveau, tapez Viandelle, choisissez Snacks, tapez 2.80 et « Une saucisse de viande hachée, frite à la commande. ». Le navigateur arrive sur /produits/17, la fiche de la viandelle à 2,80 €.

Envoyez maintenant le formulaire sans rien remplir. Le navigateur affiche une erreur 500 :

Illuminate\Database\QueryException
SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: products.category_id (Connection: sqlite, …, SQL: insert into "products" ("name", "category_id", "price", "description", "updated_at", "created_at") values (?, ?, ?, ?, 2026-10-07 15:27:23, 2026-10-07 15:27:23))

Laravel transforme chaque champ vide en null. La colonne category_id n'accepte pas null, et la base refuse la ligne. Le message ne cite que la première colonne fautive de la table, mais name et price manquent aussi.

La base ne refuse pas tout. Tapez le nom « Frite géante de la maison avec supplément de sauce et oignons frits croustillants », choisissez Frites et un prix de 0. /produits/18 affiche ces 81 caractères à 0,00 €. La migration déclare string('name', 60), mais SQLite ne vérifie pas cette longueur, et aucune colonne n'interdit un prix de zéro.

3. Valider

Vérifier chaque champ reçu avant de s'en servir s'appelle la validation. La méthode validate() de la requête reçoit, pour chaque champ, une liste de conditions :

public function store(Request $request): RedirectResponse
{
    $validated = $request->validate([
        'name' => ['required', 'string', 'max:60'],
        'category_id' => ['required', 'integer', 'exists:categories,id'],
        'price' => ['required', 'numeric', 'min:0.5', 'max:50'],
        'description' => ['nullable', 'string', 'max:500'],
    ]);

    $product = Product::create($request->only(['name', 'category_id', 'price', 'description']));

    return redirect()->route('products.show', $product->id);
}

Chaque condition s'appelle une règle :

Règle Ce qu'elle vérifie
required le champ est rempli
string, max:60 un texte de 60 caractères au plus
numeric, min:0.5, max:50 un nombre entre 0,5 et 50
nullable le champ peut rester vide, et ses autres règles ne s'appliquent alors pas
integer un nombre entier, et non un texte ou une liste de valeurs
exists:categories,id la table categories a une ligne dont l'id vaut la valeur reçue

Pour exists, Laravel envoie select count(*) as "aggregate" from "categories" where "id" = ?, avec la valeur reçue à la place du ?. Le <select> ne propose que quatre catégories, mais les outils de développement changent une option en deux clics. Une requête modifiée peut même envoyer une liste de numéros, que exists accepterait : integer la refuse. De même, min="0" fait bloquer un prix négatif par le navigateur, mais une requête modifiée passe sans ce contrôle. Vérifiez chaque champ sur le serveur, même ceux que le navigateur contrôle déjà.

Quand une règle échoue

Ouvrez les outils de développement avec F12, onglet Réseau, et cochez « Conserver le journal ». Envoyez de nouveau le formulaire vide. Cette fois, le formulaire revient, vide, avec trois champs bordés de rouge et sans un mot d'explication. L'onglet Réseau montre deux requêtes : produits en POST, code 302, puis nouveau en GET, code 200. La réponse à la première commence ainsi :

HTTP/1.1 302 Found
…
Location: http://friterie-26.test/produits/nouveau
…

Dans l'ordre, Laravel a fait ceci :

  1. validate() a refusé trois champs, et la suite de store() n'a pas été exécutée.
  2. Laravel a rangé dans la session les messages d'erreur et les valeurs tapées.
  3. Il a répondu par une redirection 302 vers la page d'où venait la requête.
  4. Le navigateur a suivi la redirection, et redemandé le formulaire.

La session, vue au chapitre 9, est un espace de stockage que le serveur réserve à chaque visiteur. Les erreurs et les valeurs tapées y attendent la requête suivante, celle du formulaire.

Le POST refusé, la session qui garde erreurs et valeurs, la redirection 302, le GET du formulaire

Afficher les erreurs

Dans chaque vue, Laravel fournit la variable $errors, qui contient les messages rangés dans la session. Les bordures rouges viennent des composants, qui lisent cette variable. Ouvrez resources/views/components/input.blade.php :

{{--
    Un champ de saisie. Le composant lit lui-même $errors : si le serveur a refusé la valeur,
    la bordure passe en rouge et le champ annonce son message (aria-invalid, aria-describedby).
    La vue garde @error('name') … @enderror sous le champ, avec id="name-error".
    Classes de l'exercice 14.7 du chapitre Tailwind.
--}}
@props(['name', 'type' => 'text'])

@php
    $invalid = $errors->has($name);
@endphp

<input
    type="{{ $type }}"
    name="{{ $name }}"
    @if ($invalid) aria-invalid="true" aria-describedby="{{ $name }}-error" @endif
    {{ $attributes->merge(['id' => $name])->class([
        'mt-2 block w-full rounded-lg border bg-white px-3 py-2 shadow-xs focus:ring-3 focus:outline-hidden dark:bg-zinc-950',
        'border-zinc-300 placeholder:text-zinc-400 focus:border-brand-600 focus:ring-brand-600/20 dark:border-zinc-700' => ! $invalid,
        'border-red-500 text-red-900 focus:border-red-600 focus:ring-red-600/20 dark:border-red-400 dark:text-red-100' => $invalid,
    ]) }}
>

@php … @endphp exécute quelques lignes de PHP dans le template. Ici, elles ne lisent pas la base comme au chapitre 5 : elles rangent une condition d'affichage dans $invalid. $errors->has('name') vaut true quand le champ name a été refusé. Le composant choisit alors les classes rouges et les attributs aria-* de l'exercice 14.7. Le message, lui, s'écrit dans la vue, sous chaque champ :

<x-layouts.app title="Ajouter un produit">
    <div class="mx-auto max-w-2xl">
        <h1 class="mb-8 text-3xl font-semibold tracking-tight">Ajouter un produit</h1>

        <form method="POST" action="{{ route('products.store') }}" class="space-y-6 rounded-xl border border-zinc-200 bg-white p-6 shadow-xs sm:p-8 dark:border-zinc-800 dark:bg-zinc-900">
            @csrf

            <div>
                <x-label for="name">Nom</x-label>
                <x-input name="name" :value="old('name')" />
                @error('name')
                    <p id="name-error" class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
                @enderror
            </div>

            <div>
                <x-label for="category_id">Catégorie</x-label>
                <x-select name="category_id">
                    <option value="">Choisissez une catégorie</option>
                    @foreach ($categories as $category)
                        <option value="{{ $category->id }}" @selected(old('category_id') == $category->id)>{{ $category->name }}</option>
                    @endforeach
                </x-select>
                @error('category_id')
                    <p id="category_id-error" class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
                @enderror
            </div>

            <div>
                <x-label for="price">Prix (€)</x-label>
                <x-input name="price" type="number" step="0.10" min="0" :value="old('price')" />
                @error('price')
                    <p id="price-error" class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
                @enderror
            </div>

            <div>
                <x-label for="description">Description</x-label>
                <x-textarea name="description">{{ old('description') }}</x-textarea>
                @error('description')
                    <p id="description-error" class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
                @enderror
            </div>

            <div class="flex justify-end">
                <x-button type="submit">Ajouter le produit</x-button>
            </div>
        </form>
    </div>
</x-layouts.app>
  • @error('name') n'affiche son contenu que si le champ name a un message, rangé dans $message. L'id name-error est celui qu'annonce aria-describedby.
  • old('name') renvoie la valeur tapée avant la redirection, ou null. Les deux-points de :value transmettent l'expression PHP, comme au chapitre 9.
  • @selected(…) écrit selected quand la condition est vraie, et {{ old('description') }} remplit le slot du <x-textarea>.

Envoyez de nouveau le formulaire vide :

The name field is required.
The category id field is required.
The price field is required.

Tapez ensuite Boulette et 3.50, sans catégorie. Le formulaire revient avec un seul message, sous la catégorie, et garde le nom et le prix. Quand une règle échoue, @error dit ce qui ne va pas, et old() rend ce qui avait été tapé.

$validated

validate() renvoie un tableau, rangé ici dans $validated. Ajoutez dd($validated); sous l'appel, puis envoyez Boulette, Snacks, 3.50, sans description :

array:4 [▼ // app/Http/Controllers/ProductController.php:43
  "name" => "Boulette"
  "category_id" => "2"
  "price" => "3.50"
  "description" => null
]

Le tableau ne contient que les champs qui ont des règles et qui figurent dans la requête, avec des valeurs qui les respectent. Retirez le dd(), et remplacez la ligne de create() :

$product = Product::create($validated);

Attention

#[Fillable] ne remplace pas $validated. Sa liste contient sold_out_at, la colonne de la rupture. $request->all() renvoie tous les champs reçus, cités ou non dans les règles. Avec Product::create($request->all()), une requête qui ajoute un champ sold_out_at crée un produit déjà en rupture, sans qu'aucune règle l'ait examiné.

Remettez la carte à neuf, pour retirer la frite géante à zéro euro (sortie raccourcie) :

php artisan migrate:fresh --seed
…
   INFO  Seeding database.

  Database\Seeders\CategorySeeder .................................... RUNNING
  Database\Seeders\CategorySeeder ................................. 36 ms DONE

  Database\Seeders\AllergenSeeder .................................... RUNNING
  Database\Seeders\AllergenSeeder ................................. 62 ms DONE

  Database\Seeders\ProductSeeder ..................................... RUNNING
  Database\Seeders\ProductSeeder ................................ 229 ms DONE

La carte retrouve ses seize produits, sans la viandelle.

4. Les messages en français

Nadia lit « The category id field is required. ». Au chapitre 6, vous avez vu qu'un projet neuf n'a pas de dossier lang/ pour les traductions. Le paquet Laravel-Lang fournit les traductions de tous les messages du framework. Installez-le :

composer require laravel-lang/common --dev

Sortie raccourcie, la commande installe vingt-cinq paquets :

./composer.json has been updated
Running composer update laravel-lang/common
…
  - Installing laravel-lang/common (6.8.0): Extracting archive
…
Using version ^6.8 for laravel-lang/common

Copiez ensuite les fichiers français dans le projet :

php artisan lang:add fr

Sortie raccourcie :

   INFO  Collecting translations...
…
   INFO  Storing changes...

  fr.json ....................................................... 12.22ms DONE
  fr/actions.php ................................................ 66.85ms DONE
  fr/auth.php .................................................... 3.64ms DONE
  fr/http-statuses.php ........................................... 4.88ms DONE
  fr/pagination.php .............................................. 3.08ms DONE
  fr/passwords.php ............................................... 2.78ms DONE
  fr/validation.php ............................................. 30.08ms DONE

lang:add crée le dossier lang/ à la racine, avec un dossier fr/ et un fichier fr.json à côté. fr/validation.php contient un message par règle. fr.json traduit les phrases que Laravel écrit directement en anglais, comme le titre de la page 404.

lang/
├── fr/
│   ├── actions.php
│   ├── auth.php
│   ├── http-statuses.php
│   ├── pagination.php
│   ├── passwords.php
│   └── validation.php
└── fr.json

--dev range le paquet parmi les dépendances de développement, comme Pest. Les traductions copiées dans lang/ font partie du projet : le serveur qui héberge le site n'a besoin que d'elles. Le chapitre Localisation rangera aussi dans lang/ les textes du site lui-même.

Dans .env, passez la langue de l'application au français, et faites la même modification dans .env.example, son modèle versionné :

APP_LOCALE=fr

Envoyez de nouveau le formulaire vide :

Le champ nom est obligatoire.
Le champ category id est obligatoire.
Le champ prix est obligatoire.

Dans lang/fr/validation.php, le message de required est 'Le champ :attribute est obligatoire.'. Laravel remplace :attribute par le nom du champ, traduit par le tableau attributes en bas du fichier. Ce tableau contient déjà name et price. Pour category_id, absent, Laravel remplace le _ par une espace. Ajoutez la ligne après 'body', dans l'ordre alphabétique :

'category_id'              => 'catégorie',
Le champ nom est obligatoire.
Le champ catégorie est obligatoire.
Le champ prix est obligatoire.

Laissez maintenant le nom et la catégorie vides, tapez 2.80 dans le prix, et envoyez. Le formulaire revient avec deux messages, et garde le prix :

Le formulaire d'ajout : nom et catégorie refusés en français, le prix conservé

5. Le message flash

Après un ajout, Nadia arrive sur la fiche sans confirmation. Dans store(), ajoutez with() à la redirection :

return redirect()->route('products.show', $product->id)->with('status', 'Le produit a été ajouté.');

Le navigateur ne garde de la session qu'un identifiant, dans le cookie friterie-chez-nadia-session. Les données restent dans la table sessions, car .env contient SESSION_DRIVER=database.

with('status', …) écrit la phrase dans la session, sous la clé status, pour la requête suivante seulement. Une valeur gardée ainsi le temps d'une requête s'appelle un message flash. Les erreurs et les valeurs tapées de la section 3 voyagent de la même façon.

Dans resources/views/components/layouts/app.blade.php, en haut de <main>, remplacez le commentaire {{-- Le chapitre 15 affiche ici le message flash. --}} :

@session('status')
    <p role="status" class="mb-8 rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm font-medium text-green-800 dark:border-green-900 dark:bg-green-950 dark:text-green-200">
        {{ $value }}
    </p>
@endsession

@session('status') n'affiche son contenu que si la session contient la clé status. Dans ce bloc, $value contient la phrase rangée sous cette clé. role="status" demande aux lecteurs d'écran d'annoncer le message. Ajoutez de nouveau la viandelle : /produits/17 s'ouvre sous un encadré vert.

Le produit a été ajouté.

Rechargez la page : l'encadré a disparu, car la requête précédente a consommé le message. Un message flash s'affiche une fois, sur la page qui suit la redirection.

6. La liaison de modèle

Dans le dépôt, show() cherche le produit elle-même, comme au chapitre 10 :

public function show(int $id): View
{
    $product = Product::findOrFail($id);

    return view('products.show', ['product' => $product]);
}

Laravel peut faire ce findOrFail à votre place. Dans routes/web.php, renommez le paramètre en {product}. whereNumber('id') porte sur un paramètre qui n'existe plus : retirez-le. Simplifiez ensuite show() :

Route::get('/produits/{product}', [ProductController::class, 'show'])->name('products.show');
public function show(Product $product): View
{
    return view('products.show', ['product' => $product]);
}

Le paramètre {product} et l'argument $product portent le même nom, et l'argument a pour type un modèle. Laravel cherche alors le produit dont l'id vaut la valeur lue dans l'adresse, le passe à la méthode, et répond 404 s'il ne le trouve pas. Ce mécanisme s'appelle la liaison de modèle.

/produits/17 affiche la viandelle. /produits/999 et /produits/abc répondent 404, avec « Non trouvé », traduit par fr.json.

Sans whereNumber, l'ordre des routes compte. Déplacez /produits/nouveau sous /produits/{product} : le formulaire répond 404, car Laravel cherche un produit dont l'id vaut nouveau. Remettez la route au-dessus.

php artisan route:list --path=produits
  POST       produits ................................... products.store › ProductController@store
  GET|HEAD   produits/nouveau ......................... products.create › ProductController@create
  GET|HEAD   produits/{product} ........................... products.show › ProductController@show

                                                                                Showing [3] routes

findOrFail() reste correct, et les deux écritures donnent la même page. La liaison évite de répéter cette ligne dans chaque méthode qui reçoit un produit.

À retenir

  • Un formulaire d'ajout demande deux routes : un GET qui l'affiche, et un POST qui reçoit ses champs.
  • validate() vérifie chaque champ avec ses règles, et renvoie les seules valeurs validées, à passer à create().
  • Quand une règle échoue, Laravel range les erreurs et les valeurs tapées dans la session, puis renvoie au formulaire, qui les affiche avec @error et old().
  • Laravel-Lang copie les messages traduits dans lang/, et le tableau attributes donne à chaque champ son nom français.
  • with() place un message flash dans la session, et la page qui suit la redirection l'affiche une seule fois.

Exercices

Comptez 20 minutes pour ces deux exercices sur papier. Les exercices de code viennent au chapitre 17, après le chapitre 16 : vous y écrirez le site d'un refuge pour animaux, avec ce que la friterie vous a appris.

15.1 Prédire les messages (sur papier, 10 minutes)

La méthode store() de la section 3 reçoit ces trois requêtes, écrites comme le navigateur les envoie, avec + pour une espace. La troisième a été modifiée dans les outils de développement. Pour chaque champ refusé, cherchez dans lang/fr/validation.php le message de la règle qui échoue, puis remplacez :attribute et les autres marques par leur valeur. Écrivez les messages affichés, une fois category_id ajouté à attributes.

1. name=Cornet+de+frites&category_id=&price=3&description=
2. name=Sauce+brasil&category_id=3&price=0.30&description=
3. name=MitrailletteMitrailletteMitrailletteMitrailletteMitrailletteMitraillette&category_id=99&price=60&description=

15.2 Le bon verbe (sur papier, 10 minutes)

Relisez la route du code qui sent mauvais, à la section 1 :

Route::get('/produits/{id}/retirer', fn (int $id) => Product::findOrFail($id)->delete());

Pour chaque action, choisissez le verbe HTTP parmi ceux du chapitre 7, et dites si elle part d'un lien ou d'un formulaire avec @csrf.

  1. Afficher la carte.
  2. Afficher le formulaire d'ajout.
  3. Enregistrer un nouveau produit.
  4. Corriger le prix de la Grande frite.
  5. Marquer la Sauce lapin en rupture.
  6. Retirer le café de la carte.

Six cartes

Que fait validate() quand une règle échoue ?

Il arrête la méthode, range les erreurs et les valeurs tapées dans la session, et redirige vers le formulaire.

À quoi sert old() ?

À réafficher dans le champ la valeur tapée avant la redirection.

Dans @error, d'où vient $message ?

De $errors, les messages que la validation a rangés dans la session.

Pourquoi exists:categories,id ?

Parce qu'une requête peut envoyer n'importe quel numéro, et que la catégorie doit exister dans la table.

Que fait ->with('status', …) ?

Il écrit un message flash dans la session, que @session('status') affiche une seule fois.

Pourquoi --dev suffit-il pour Laravel-Lang ?

Parce que lang:add copie les traductions dans lang/, et le site en ligne n'a besoin que de ces fichiers.