Formulaires : modifier et supprimer
Bloc Laravel · séance 8 · ~60 min
Au chapitre 15, Nadia a ajouté la viandelle à sa carte. Une carte change pourtant chaque semaine : un prix augmente, un allergène a été oublié, les croquettes manquent un samedi soir, une sauce ne se vend plus. Dans ce chapitre, vous écrivez les formulaires qui modifient un produit, le marquent en rupture et le retirent de la carte. Vous comparez ensuite vos routes à celles que Route::resource() écrirait.
À la fin de ce chapitre, vous serez capables de :
- pré-remplir un formulaire de modification et l'envoyer en
PUT, - expliquer le champ caché
_method, - partager les règles de validation entre l'ajout et la modification,
- enregistrer des cases à cocher avec
sync(), - supprimer une ligne depuis un formulaire avec confirmation.
1. Le code qui sent mauvais
En binôme, cinq minutes. Un collègue de Nadia écrit le formulaire qui modifie la Grande frite. Il a retenu du chapitre 7 qu'une modification s'envoie avec le verbe PUT, et l'écrit dans l'attribut method :
<form method="PUT" action="/produits/2">
@csrf
…
<x-button type="submit">Enregistrer</x-button>
</form>
Nadia passe le prix de 4,00 € à 4,50 € et clique sur Enregistrer. Quelle requête le navigateur envoie-t-il, et qu'affiche la page ?
2. Modifier
Il faut deux routes, comme pour l'ajout : l'une affiche le formulaire, l'autre reçoit ce qu'il envoie. Ajoutez-les sous la route products.show :
Route::get('/produits/{product}/modifier', [ProductController::class, 'edit'])->name('products.edit');
Route::put('/produits/{product}', [ProductController::class, 'update'])->name('products.update');
Grâce à la liaison de modèle du chapitre 15, section 6, edit() reçoit le produit déjà lu dans la base, sans findOrFail(). Elle le passe à la vue avec les catégories :
public function edit(Product $product): View
{
$categories = Category::orderBy('position')->get();
return view('products.edit', ['product' => $product, 'categories' => $categories]);
}
php artisan make:view products.edit
INFO View [resources/views/products/edit.blade.php] created successfully.
Remplacez tout le contenu du fichier, un <div> et une citation, par le formulaire d'ajout, avec cinq changements : le titre, l'adresse d'envoi, la ligne @method('PUT'), le second argument de chaque old() et le texte du bouton.
<x-layouts.app :title="'Modifier ' . $product->name">
<div class="mx-auto max-w-2xl">
<h1 class="mb-8 text-3xl font-semibold tracking-tight">Modifier {{ $product->name }}</h1>
<form method="POST" action="{{ route('products.update', $product->id) }}" 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
@method('PUT')
<div>
<x-label for="name">Nom</x-label>
<x-input name="name" :value="old('name', $product->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', $product->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', $product->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', $product->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">Enregistrer</x-button>
</div>
</form>
</div>
</x-layouts.app>
Ouvrez http://friterie-26.test/produits/2/modifier : les quatre champs affichent la Grande frite. old('name', $product->name) renvoie la valeur gardée dans la session après un refus, et sinon son second argument. Au premier affichage, la session ne contient aucune valeur tapée, et le champ montre le nom enregistré. Après un refus, il montre ce que Nadia avait tapé.
Le champ caché _method
Affichez le code source de la page avec Ctrl+U. Sous la balise <form>, @csrf et @method('PUT') ont écrit deux champs cachés :
<form method="POST" action="http://friterie-26.test/produits/2" class="…">
<input type="hidden" name="_token" value="K5vj…" autocomplete="off"> <input type="hidden" name="_method" value="PUT">
Le formulaire part en POST, le seul de ses deux verbes qui envoie les champs hors de l'adresse. Avant de chercher la route, Laravel regarde si une requête POST contient un champ _method. Si oui, il prend sa valeur comme verbe, puis cherche dans routes/web.php une route PUT /produits/{product}. La directive @method('PUT') ne fait qu'écrire ce champ.

Enregistrer la modification
La méthode update() reçoit la requête et le produit. Elle valide les champs comme store(), puis modifie la ligne :
public function update(Request $request, Product $product): 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->update($validated);
return redirect()->route('products.show', $product->id)->with('status', 'Le produit a été modifié.');
}
C'est le update() du chapitre 10, appelé cette fois par un formulaire : Nadia change un prix sans toucher au PHP. Tapez 4.20 dans le prix de la Grande frite et enregistrez. La fiche s'ouvre sous l'encadré vert, et la carte suit :
Le produit a été modifié.
4,20 €
Un prix de 0 renvoie au formulaire avec « La valeur de prix doit être supérieure ou égale à 0.5. », et le champ garde le 0 tapé.
Retirez maintenant la ligne @method('PUT') et envoyez de nouveau le formulaire. Laravel répond par le code 405, comme pour DELETE /livres au chapitre 7 :
Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException
The POST method is not supported for route produits/2. Supported methods: GET, HEAD, PUT.

L'adresse /produits/2 n'a pas de route POST. Remettez la ligne. Pour une modification, HTTP propose deux verbes : PUT remplace toute la ressource, PATCH n'en change qu'une partie. Le formulaire envoie tous les champs de la fiche, et update() les remplace tous : c'est un PUT. La rupture, qui ne touche qu'une colonne, aura sa propre route PATCH.
Il manque un chemin vers ce formulaire. Dans products/show.blade.php, sous le bloc des allergènes, ajoutez un bouton « Modifier ». La prop href du composant button en fait un lien :
<div class="mt-8 flex flex-wrap gap-3 border-t border-zinc-200 pt-6 dark:border-zinc-800">
<x-button variant="secondary" :href="route('products.edit', $product->id)">Modifier</x-button>
</div>
3. Les règles écrites une fois
store() et update() répètent les mêmes quatre règles. Le jour où Nadia accepte des noms de 80 caractères, il faudra penser aux deux. Laravel peut ranger ces règles dans une classe à part :
php artisan make:request ProductRequest
INFO Request [app/Http/Requests/ProductRequest.php] created successfully.
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class ProductRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return false;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
//
];
}
}
Copiez le tableau de validate() dans le return de rules(). Dans ProductController, importez la classe, puis changez le type du paramètre $request de store() et d'update() :
use App\Http\Requests\ProductRequest;
public function update(ProductRequest $request, Product $product): RedirectResponse
{
$product->update($request->validated());
return redirect()->route('products.show', $product->id)->with('status', 'Le produit a été modifié.');
}
Faites de même dans store(), avec Product::create($request->validated()), et supprimez l'import de Request, qui ne sert plus. Enregistrez la Grande frite :
403 | Cette action n'est pas autorisée.
Laravel répond par le code 403, « interdit » : il a appelé authorize() avant tout le reste, et la méthode a répondu false. Elle sert à vérifier que la personne connectée a le droit d'envoyer ce formulaire. Le site de Nadia n'a pas de comptes, et l'autorisation est au programme du cours « Projet Web Dynamique ». Faites renvoyer true à authorize(), puis réessayez : la fiche affiche « Le produit a été modifié. ».
Une classe comme ProductRequest s'appelle une Form Request : elle contient les règles d'un formulaire. Laravel voit le type ProductRequest dans la signature, crée l'objet, appelle authorize() puis valide les champs avec rules(). Il n'appelle update() que si tout passe. Sinon, il renvoie au formulaire avec les erreurs, comme au chapitre 15, section 3. $request->validated() donne le tableau des champs validés, comme $validated. Une règle de validation s'écrit une fois, dans la Form Request, et sert à chaque méthode qui la reçoit.
4. Créer depuis la catégorie
Le samedi, Nadia ajoute souvent une sauce. Sur /categories/3, la page des sauces, un formulaire court lui suffirait : la catégorie est déjà dans l'adresse. Remplacez d'abord {id} par {category} sur la route categories.show et retirez son whereNumber('id'), comme pour products.show au chapitre 15. Ajoutez ensuite la route qui reçoit ce formulaire :
Route::get('/categories/{category}', [CategoryController::class, 'show'])->name('categories.show');
Route::post('/categories/{category}/produits', [CategoryController::class, 'storeProduct'])->name('categories.products.store');
Dans CategoryController, show() reçoit maintenant Category $category et perd sa ligne findOrFail. Importez ProductRequest et RedirectResponse, puis ajoutez :
public function storeProduct(ProductRequest $request, Category $category): RedirectResponse
{
$category->products()->create($request->validated());
return redirect()->route('categories.show', $category->id)->with('status', 'Le produit a été ajouté.');
}
products(), avec parenthèses, renvoie la relation du chapitre 11. Sa méthode create() enregistre un produit et remplit elle-même category_id avec l'id de la catégorie. Dans categories/show.blade.php, sous la liste des produits, ajoutez le formulaire, sans <select> :
<form method="POST" action="{{ route('categories.products.store', $category->id) }}" class="mt-12 max-w-2xl 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
<h2 class="text-xl font-semibold tracking-tight">Ajouter un produit dans cette catégorie</h2>
<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="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 class="flex justify-end">
<x-button type="submit">Ajouter à {{ $category->name }}</x-button>
</div>
</form>
Tapez Sauce cocktail et 0.80, puis envoyez. Le formulaire revient avec les deux valeurs, sans message et sans encadré vert. Rien n'a été enregistré : ProductRequest exige category_id, que ce formulaire n'envoie pas, et la vue n'a pas de @error('category_id') pour le dire.
La catégorie se trouve pourtant dans l'adresse. Avant de valider, Laravel appelle la méthode prepareForValidation() de la Form Request, si elle existe. Ajoutez-la dans ProductRequest, sous authorize() :
protected function prepareForValidation(): void
{
if ($this->route('category')) {
$this->merge(['category_id' => $this->route('category')->id]);
}
}
$this->route('category') renvoie la catégorie liée au paramètre {category} de l'adresse, et null sur une route qui n'a pas ce paramètre. Quand la catégorie existe, merge() ajoute son id aux champs de la requête, comme si le formulaire avait envoyé category_id.
Envoyez de nouveau la sauce cocktail : la page des sauces s'affiche sous « Le produit a été ajouté. ».
Pour voir ce que la Form Request a gardé, ajoutez dd($request->validated()); en tête de storeProduct(), et envoyez une troisième fois Sauce cocktail et 0.80 :
array:3 [▼ // app/Http/Controllers/CategoryController.php:24
"name" => "Sauce cocktail"
"category_id" => 3
"price" => "0.80"
]
category_id vaut le nombre 3, sans guillemets, car il vient de l'adresse et non d'un champ du formulaire. Retirez le dd(). Dans SQLite Viewer, la table products montre le nouveau produit, colonnes de dates omises :
id | category_id | name | price | description
18 | 3 | Sauce cocktail | 0.8 |
Le formulaire d'ajout n'a pas de catégorie dans son adresse, et category_id y reste obligatoire. Sur /produits/nouveau, supprimez la balise <select> dans les outils de développement, tapez un nom et un prix, puis envoyez. Le formulaire revient avec « Le champ catégorie est obligatoire. », et rien n'est enregistré.
5. Les allergènes cochés
La fiche de la viandelle affiche « Aucun allergène déclaré. ». Nadia doit pouvoir cocher ses allergènes, à l'ajout comme à la modification. Plutôt que d'écrire les cases dans les deux vues, rangez d'abord les champs communs dans un composant anonyme, comme <x-alert> au chapitre 9, section 5.
Les champs écrits une fois
php artisan make:component product-fields --view
INFO View [resources/views/components/product-fields.blade.php] created successfully.
Remplacez le contenu de product-fields.blade.php par les quatre <div> des champs de edit.blade.php, sans la balise <form> ni le bouton. Ajoutez en tête la directive @props et un bloc @php, puis les cases sous la description :
@props(['product', 'categories', 'allergens'])
@php
$checkedAllergens = session()->hasOldInput()
? (array) old('allergens', [])
: $product->allergens->pluck('id')->all();
@endphp
<div>
<x-label for="name">Nom</x-label>
<x-input name="name" :value="old('name', $product->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', $product->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', $product->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', $product->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>
<fieldset>
<legend class="text-sm font-medium">Allergènes</legend>
<div class="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-4">
@foreach ($allergens as $allergen)
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" name="allergens[]" value="{{ $allergen->id }}" @checked(in_array($allergen->id, $checkedAllergens)) class="size-4 accent-brand-600">
{{ $allergen->name }}
</label>
@endforeach
</div>
@error('allergens.*')
<p class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
</fieldset>
Comme au chapitre 9, section 5, @props déclare ce que le composant attend : le produit, les catégories du <select> et les allergènes des cases. Le bloc @php range dans $checkedAllergens les id des allergènes à cocher. edit.blade.php se réduit à ceci :
<form method="POST" action="{{ route('products.update', $product->id) }}" class="…">
@csrf
@method('PUT')
<x-product-fields :product="$product" :categories="$categories" :allergens="$allergens" />
<div class="flex justify-end">
<x-button type="submit">Enregistrer</x-button>
</div>
</form>
Les deux-points transmettent les variables elles-mêmes, et non leur nom écrit en texte, comme :concert="$concert" au chapitre 9. create.blade.php se réduit de même, avec route('products.store'), sans @method, et avec le bouton « Ajouter le produit ».
Les deux vues transmettent $allergens. create() et edit() passent donc la liste des allergènes, et create() un produit vide. Importez Allergen dans le contrôleur :
return view('products.create', [
'product' => new Product,
'categories' => $categories,
'allergens' => Allergen::all(),
]);
return view('products.edit', [
'product' => $product,
'categories' => $categories,
'allergens' => Allergen::all(),
]);
new Product crée un objet absent de la base : ses colonnes valent null et il n'a aucun allergène. Dans le formulaire d'ajout, old('name', $product->name) renvoie donc null, comme old('name') au chapitre 15. Ouvrez /produits/nouveau puis /produits/2/modifier : les deux formulaires affichent les huit cases.
Ce que les cases envoient
Chaque case porte le nom allergens[]. Les crochets demandent à PHP de ranger toutes les valeurs cochées dans un tableau, $request->input('allergens'). La valeur de chaque case est l'id de son allergène. pluck('id')->all() donne le tableau des id des allergènes du produit, et @checked(…) écrit checked quand l'id de la case s'y trouve. Dans le code source de /produits/3/modifier, la case Gluten de la fricadelle est cochée :
<input type="checkbox" name="allergens[]" value="1" checked class="size-4 accent-brand-600">
Ajoutez dd($request->all()); en tête d'update(). Cochez Gluten et Soja pour la viandelle, puis enregistrez. Cliquez sur la flèche d'allergens pour déplier le tableau :
array:7 [▼ // app/Http/Controllers/ProductController.php:67
"_token" => "2H4wT0Ci…"
"_method" => "PUT"
"name" => "Viandelle"
"category_id" => "2"
"price" => "2.80"
"description" => "Une saucisse de viande hachée, frite à la commande."
"allergens" => array:2 [▼
0 => "1"
1 => "5"
]
]
Revenez au formulaire, décochez les deux cases, et enregistrez :
array:6 [▼ // app/Http/Controllers/ProductController.php:67
"_token" => "2H4wT0Ci…"
"_method" => "PUT"
"name" => "Viandelle"
"category_id" => "2"
"price" => "2.80"
"description" => "Une saucisse de viande hachée, frite à la commande."
]
Un navigateur n'envoie pas une case décochée. Sans case cochée, la clé allergens est absente de la requête. Retirez le dd().
C'est pour cela que le composant ne se contente pas de old('allergens', …). session()->hasOldInput() dit si un envoi précédent a laissé des valeurs dans la session. Après un refus, une liste absente veut dire qu'aucune case n'était cochée, et old('allergens', []) renvoie un tableau vide. Au premier affichage, la session ne contient rien, et les cases reprennent les allergènes enregistrés.
Sur /produits/3/modifier, décochez Gluten et Soja, tapez un prix de 0, puis enregistrez. Le formulaire revient avec le message du prix, et la case Gluten reste décochée :
<input type="checkbox" name="allergens[]" value="1" class="size-4 accent-brand-600">
Avec old('allergens', $product->allergens->pluck('id')->all()), old() ne trouverait pas de clé allergens dans la session, et Gluten et Soja reviendraient cochés.
Reste le (array) devant old(). Les crochets d'allergens[] sont une convention du navigateur, et rien n'oblige à la suivre : avec curl, ou en renommant la case allergens dans les outils de développement, on envoie allergens=1, une valeur seule. Les règles de la section suivante la refuseront, mais le formulaire sera réaffiché, et old('allergens', []) renverra la chaîne "1". Sans le (array), in_array() s'arrête :
in_array(): Argument #2 ($haystack) must be of type array, string given
(array) convertit cette chaîne en tableau d'un élément, ["1"], et laisse un tableau tel quel. La case 1 revient cochée, et la page s'affiche. Une vue ne fait pas plus confiance à la requête qu'un contrôleur : ce que le navigateur envoie d'habitude n'est pas ce qu'il envoie toujours.
Valider et enregistrer
Ajoutez deux règles dans ProductRequest :
'allergens' => ['nullable', 'array'],
'allergens.*' => ['integer', 'exists:allergens,id'],
La première accepte un tableau ou rien. Dans allergens.*, l'étoile désigne chaque élément du tableau : chaque valeur reçue doit être un entier, id d'une ligne d'allergens. Cochez Gluten et Soja, puis changez la valeur de la case Soja en 99 dans les outils de développement. L'envoi donne « Le champ allergens.1 sélectionné est invalide. ».
Laravel nomme chaque case par sa position dans le tableau envoyé : allergens.0, allergens.1. La méthode facultative attributes() d'une Form Request donne un nom aux champs de ce formulaire seulement, comme le tableau attributes de lang/fr/validation.php :
public function attributes(): array
{
return [
'allergens.*' => 'allergène',
];
}
Le message devient « Le champ allergène sélectionné est invalide. ». Sa voisine messages() remplace le message entier d'une règle. Avec 'name.required' => 'Donnez un nom au produit.' dans son tableau, un nom vide affiche « Donnez un nom au produit. ».
Dans update(), enregistrez ensuite la liste cochée :
$validated = $request->validated();
$product->update($validated);
$product->allergens()->sync($validated['allergens'] ?? []);
Vous connaissez sync() depuis le chapitre 12, section 4 : il rend les lignes du produit dans allergen_product identiques à la liste reçue. ?? [] lui donne un tableau vide quand aucune case n'est cochée, et sync([]) retire alors tous les allergènes. update() ignore la clé allergens, absente de #[Fillable], comme au chapitre 10. Faites le même ajout dans store() : rangez $request->validated() dans $validated, passez-le à Product::create(), puis appelez sync() sur $product.
Affichez les lignes de la viandelle dans la table pivot, avant puis après avoir coché et enregistré Gluten et Soja :
php artisan tinker --execute="echo DB::table('allergen_product')->where('product_id', 17)->get();"
[]
[{"allergen_id":1,"product_id":17},{"allergen_id":5,"product_id":17}]
6. Marquer en rupture
Un samedi soir, les croquettes de fromage manquent. Nadia ne veut pas les retirer de la carte, seulement les marquer « En rupture ». La colonne sold_out_at existe déjà : le badge s'affiche dès qu'elle contient une date. L'action ne change qu'une colonne du produit : c'est un PATCH.
Route::patch('/produits/{product}/rupture', [ProductController::class, 'soldOut'])->name('products.sold-out');
public function soldOut(Product $product): RedirectResponse
{
if (! $product->isSoldOut()) {
$product->update(['sold_out_at' => now()]);
}
return redirect()->route('products.show', $product->id)->with('status', 'Le produit est marqué en rupture.');
}
now() renvoie la date et l'heure actuelles. Le if garde la date de la première rupture : un second envoi, depuis une fiche restée ouverte dans un autre onglet, ne la change pas. Sur la fiche, à côté de « Modifier », ajoutez un formulaire d'un seul bouton, affiché seulement pour un produit disponible :
@if (! $product->isSoldOut())
<form method="POST" action="{{ route('products.sold-out', $product->id) }}">
@csrf
@method('PATCH')
<x-button type="submit" variant="secondary">Marquer en rupture</x-button>
</form>
@endif
Le formulaire n'a pas de champ visible : l'adresse désigne le produit, et le verbe PATCH dit ce qu'il faut en faire. Un lien aurait été plus court, mais un robot qui le suit marquerait le produit en rupture : c'est la réponse du code qui sent mauvais du chapitre 15. Sur /produits/5, cliquez sur le bouton :
Le produit est marqué en rupture.
Croquette de fromage
En rupture
En rupture depuis 07/10/2026, 15 h 22
Le bouton a disparu, et la carte affiche le badge rouge sous les croquettes.
7. Retirer de la carte
Nadia ne vend plus de sauce tartare. Pour supprimer, HTTP propose le verbe DELETE. La route reprend l'adresse de update(), avec un autre verbe :
Route::delete('/produits/{product}', [ProductController::class, 'destroy'])->name('products.destroy');
public function destroy(Product $product): RedirectResponse
{
$product->delete();
return redirect()->route('products.index')->with('status', 'Le produit a été retiré de la carte.');
}
La fiche n'existe plus après la suppression : la redirection mène à la carte. Sur la fiche, dans le <div> des boutons, après le formulaire de rupture, ajoutez :
<form method="POST" action="{{ route('products.destroy', $product->id) }}" onsubmit="return confirm('Retirer ce produit de la carte ?')">
@csrf
@method('DELETE')
<x-button type="submit" variant="danger">Retirer de la carte</x-button>
</form>
L'attribut onsubmit contient du JavaScript, exécuté juste avant l'envoi. confirm() ouvre une boîte du navigateur avec la question, un bouton OK et un bouton Annuler. Elle renvoie false sur Annuler, et return false arrête l'envoi. confirm() évite un clic malheureux, mais ne protège rien côté serveur : une requête envoyée sans le formulaire ne passe par aucune boîte. Une boîte mise en forme avec Tailwind est un bonus du chapitre 17.
Comptez les lignes de la table pivot, retirez la Tartare, puis comptez de nouveau :
php artisan tinker --execute="echo DB::table('allergen_product')->count();"
22
20
La carte s'affiche sous « Le produit a été retiré de la carte. », sans la Tartare, et /produits/12 répond 404. La Tartare avait deux allergènes, Œuf et Moutarde. Ses deux lignes ont disparu d'allergen_product, grâce au cascadeOnDelete() de la migration, comme au chapitre 12, section 7.
8. Les routes et Route::resource()
Le menu
Dans le menu du layout, à côté de « La carte », ajoutez un lien vers le formulaire d'ajout. Restreignez aussi la condition active de « La carte », écrite avec routeIs() comme au chapitre 13, section 6, pour qu'un seul lien soit marqué à la fois :
<li><x-nav-link :href="route('products.index')" :active="request()->routeIs('products.index', 'products.show', 'categories.show')">La carte</x-nav-link></li>
<li><x-nav-link :href="route('products.create')" :active="request()->routeIs('products.create')">Ajouter un produit</x-nav-link></li>
Neuf routes écrites à la main
Voici les routes des produits, écrites à la main :
php artisan route:list --path=produits
POST categories/{category}/produits ..... categories.products.store › CategoryController@storeProduct
POST produits .............................................. products.store › ProductController@store
GET|HEAD produits/nouveau .................................... products.create › ProductController@create
GET|HEAD produits/{product} ...................................... products.show › ProductController@show
PUT produits/{product} .................................. products.update › ProductController@update
DELETE produits/{product} ................................ products.destroy › ProductController@destroy
GET|HEAD produits/{product}/modifier ............................. products.edit › ProductController@edit
PATCH produits/{product}/rupture ....................... products.sold-out › ProductController@soldOut
Showing [8] routes
Avec products.index sur /carte, ProductController donne un corps aux sept méthodes que RecipeController --resource laissait vides au chapitre 8, section 9 : index, create, store, show, edit, update et destroy. Ajoutez temporairement cette ligne à la fin de routes/web.php, puis listez les routes qu'elle déclare :
Route::resource('produits', ProductController::class);
php artisan route:list --name=produits
POST produits .............................. produits.store › ProductController@store
GET|HEAD produits .............................. produits.index › ProductController@index
GET|HEAD produits/create ..................... produits.create › ProductController@create
GET|HEAD produits/{produit} ...................... produits.show › ProductController@show
PUT|PATCH produits/{produit} .................. produits.update › ProductController@update
DELETE produits/{produit} ................ produits.destroy › ProductController@destroy
GET|HEAD produits/{produit}/edit ................. produits.edit › ProductController@edit
Showing [7] routes
Ces sept routes ne sont pas les vôtres. Les adresses finissent par create et edit, en anglais, et les noms commencent par produits.. Le paramètre {produit} ne porte pas le nom de l'argument $product : Laravel ne fait pas la liaison de modèle, show() reçoit un produit vide, et la fiche s'arrête sur Attempt to read property "id" on null.
Route::resource() peut pourtant parler français. Dans app/Providers/AppServiceProvider.php, importez Illuminate\Support\Facades\Route, puis ajoutez cette ligne dans boot() :
Route::resourceVerbs(['create' => 'nouveau', 'edit' => 'modifier']);
Relancez php artisan route:list --name=produits. Les deux adresses traduites deviennent :
GET|HEAD produits/nouveau .................... produits.create › ProductController@create
GET|HEAD produits/{produit}/modifier ............. produits.edit › ProductController@edit
Ajoutés à Route::resource(), ->names('products') et ->parameters(['produits' => 'product']) corrigeraient aussi les noms et le paramètre. /produits/{product}/rupture et /categories/{category}/produits resteraient pourtant hors de Route::resource(), écrites à la main de toute façon. Le cours garde donc ses neuf routes écrites à la main : chaque ligne de routes/web.php se lit sans rien chercher ailleurs. Retirez la ligne Route::resource(), puis celle de resourceVerbs() et son import.
À retenir
- Un formulaire de modification se pré-remplit avec
old('name', $product->name): la valeur tapée après un refus, sinon la valeur enregistrée. - Un formulaire HTML n'envoie que
GETouPOST, et@method('PUT')ajoute le champ caché_methodque Laravel lit pour choisir la route. - Une Form Request range les règles d'un formulaire, et Laravel valide avant d'appeler la méthode qui la reçoit.
- Un navigateur n'envoie pas une case décochée, et
sync($validated['allergens'] ?? [])remplace la liste entière, vide comprise. - Tout ce qui modifie la base part d'un formulaire
POSTavec@csrf, et une suppression demande confirmation avecconfirm().
Exercices
Comptez 15 minutes pour cet exercice sur papier. Les katas du refuge, au chapitre 17, viennent ensuite : ils reprennent tout ce que les chapitres 15 et 16 ont montré sur la friterie.
16.1 Qui reçoit la requête ? (sur papier, 15 minutes)
Relisez le route:list de la section 8. Pour chaque requête, écrite comme le navigateur l'envoie, dites quelle méthode de quel contrôleur la reçoit. Si aucune ne la reçoit, donnez le code de la réponse.
1. GET /produits/4/modifier
2. POST /produits/4 avec _method=PUT
3. POST /produits/4 avec _method=PATCH
4. POST /produits/4/rupture avec _method=PATCH
5. GET /produits/4/rupture
6. POST /categories/2/produits
7. GET /produits/modifier