The "U" and "D" of CRUD are three actions (edit, update, destroy) that show a pre-filled form, save the changes, and remove a record, using hidden fields to send PUT and DELETE from plain HTML forms.
โ๏ธEditing is like correcting a document with tracked changes: you start from what's already there, not a blank page. Deleting is shredding it, so you want a "are you sure?" before the shredder runs.
Close the loop on your first full feature
Data changes over time
Typos, new categories, outdated posts: users need to fix and remove what they created.
HTML has limits
Forms only support GET and POST. Laravel's method spoofing lets them trigger PUT and DELETE routes.
Deletes must be safe
A delete should never be a plain link that a crawler or a stray click can trigger.
How a form sends PUT and DELETE
@method@method('PUT') renders <input type="hidden" name="_method" value="PUT">. Laravel reads it and routes the request as PUT.| Action | Verb | URI | Form needs |
|---|---|---|---|
edit | GET | /posts/{post}/edit | Just a link |
update | PUT/PATCH | /posts/{post} | method="POST" + @csrf + @method('PUT') |
destroy | DELETE | /posts/{post} | method="POST" + @csrf + @method('DELETE') |
Finish the PostController
We continue from Create & Read: the layout already shows session('status').
-
edit: load the post into a formRoute model binding gives us the post. We also pass the categories for the dropdown.
public function edit(Post $post) { $categories = Category::orderBy('name')->get(); return view('posts.edit', compact('post', 'categories')); }Add an edit link on the show page:
<a href="{{ route('posts.edit', $post) }}">Edit</a>. -
The edit form:
@method('PUT')and pre-filled valuesold('title', $post->title)means "use what the user just typed if validation failed, otherwise the saved value". The form points atposts.updatewith the post.@extends('layouts.app') @section('content') <form method="POST" action="{{ route('posts.update', $post) }}"> @csrf @method('PUT') <label>Title <input name="title" value="{{ old('title', $post->title) }}"></label> @error('title') <p class="error">{{ $message }}</p> @enderror <label>Slug <input name="slug" value="{{ old('slug', $post->slug) }}"></label> @error('slug') <p class="error">{{ $message }}</p> @enderror <select name="category_id"> <option value="">None</option> @foreach ($categories as $category) <option value="{{ $category->id }}" @selected(old('category_id', $post->category_id) == $category->id)> {{ $category->name }} </option> @endforeach </select> <textarea name="body">{{ old('body', $post->body) }}</textarea> @error('body') <p class="error">{{ $message }}</p> @enderror <button type="submit">Save changes</button> </form> @endsection -
Validate updates with a unique-but-ignore-me rule
The slug must be unique, but the post's own slug shouldn't count as a clash.
Rule::unique(...)->ignore()excludes the current post. Inside a Form Request,$this->postis the boundPostfrom the URL.php artisan make:request UpdatePostRequestuse Illuminate\Validation\Rule; public function authorize(): bool { return $this->user()?->id === $this->post->user_id; // only the author } public function rules(): array { return [ 'title' => ['required', 'string', 'max:255'], 'slug' => ['required', 'alpha_dash', Rule::unique('posts', 'slug')->ignore($this->post)], 'body' => ['required', 'string', 'min:20'], 'category_id' => ['nullable', 'exists:categories,id'], ]; } -
update: save and redirectWith validation done,
update()writes only the validated fields and refreshesupdated_at.use App\Http\Requests\UpdatePostRequest; public function update(UpdatePostRequest $request, Post $post) { $post->update($request->validated()); return redirect() ->route('posts.show', $post) ->with('status', 'Post updated!'); } -
destroy: a delete button, not a linkDeleting uses a tiny form with
@method('DELETE'). A small JavaScriptconfirm()gives users a last chance to back out.<form method="POST" action="{{ route('posts.destroy', $post) }}" onsubmit="return confirm('Delete this post for good?')"> @csrf @method('DELETE') <button type="submit">Delete</button> </form>public function destroy(Post $post) { abort_unless(auth()->id() === $post->user_id, 403); $post->delete(); // comments go too, thanks to cascadeOnDelete() return redirect() ->route('posts.index') ->with('status', 'Post deleted.'); }Redirect to the index, not the show page: the post no longer exists, so
posts.showwould 404.โน๏ธ Cleaner ownership checks are comingThe
abort_unlessandauthorize()checks work, but they repeat. A policy puts "who can update or delete a post" in one class. See Policies.
Avoid this, do that
<a href="/posts/{{ $post->id }}/delete">Delete</a>
A GET link that deletes can be triggered by prefetching, crawlers or a malicious image tag, with no CSRF check.
<form method="POST" action="{{ route('posts.destroy', $post) }}">
@csrf @method('DELETE')
<button>Delete</button>
</form>
A DELETE request with a CSRF token only happens when a real user clicks the button.
<form method="PUT" action="{{ route('posts.update', $post) }}">
Browsers don't support method="PUT"; they silently fall back to GET and you hit the wrong route.
<form method="POST" action="{{ route('posts.update', $post) }}">
@csrf
@method('PUT')
Use POST in the HTML and let @method tell Laravel the real verb.
Add $table->softDeletes(); to the migration and use SoftDeletes; to the model. Now delete() just sets deleted_at, queries hide the post automatically, and $post->restore() brings it back.
- Build
edit.blade.phpand theedit/updateactions. Edit a post and see "Post updated!". - Submit the edit form with an empty title and confirm the other fields keep your changes.
- Try saving a slug that another post already uses, then save the post's own unchanged slug. Only the first should fail.
- Add the delete button and
destroyaction; check you land on the index with "Post deleted." - Bonus: switch
Postto soft deletes and add a "Trash" page listingPost::onlyTrashed()->get().
Key takeaways
- HTML forms use
method="POST";@method('PUT')or@method('DELETE')sets the real verb. old('field', $post->field)pre-fills edit forms and survives failed validation.Rule::unique('posts', 'slug')->ignore($post)lets a record keep its own unique value.$post->update($request->validated()), then redirect with astatusflash.- Delete with a form and a confirm, never a GET link; redirect to the index afterwards.
- Check ownership before changing data. Policies make that tidy.