A live API, right in your browser
This works just like the Laravel API you'll build on this page. Click the presets from left to right and watch the database table at the bottom.
After Soft delete, post #3 disappeared from Get all, but it's still in the table with a date in deleted_at. Then Restore brought it back. That's the whole idea of soft deletes, and you'll build it below.
CRUD ↔ HTTP ↔ Laravel
Screenshot this table. It's 90% of every API you'll ever build.
| I want to… | Request | Controller method | Success reply |
|---|---|---|---|
| ➕ Create a post | POST /api/posts | store() | 201 Created |
| 📋 Get all posts | GET /api/posts | index() | 200 OK |
| 🔎 Get one post | GET /api/posts/1 | show() | 200 OK (or 404) |
| ♻️ Replace a post | PUT /api/posts/1 | update() | 200 OK |
| ✏️ Edit part of a post | PATCH /api/posts/1 | update() | 200 OK |
| 🗑️ Delete a post | DELETE /api/posts/1 | destroy() | 204 No Content |
-
Turn on API routes
New Laravel apps don't include an API file by default. One command adds
routes/api.php(every URL in it automatically starts with/api).php artisan install:api php artisan make:model Post -mc --apiThe second command makes three things at once: a model (
-madds a migration,-ca controller), and--apifills the controller with the 5 API methods. -
Describe the table (migration)
A migration is a recipe for a database table. Notice the last line:
softDeletes()adds adeleted_atcolumn. We'll need it later.Schema::create('posts', function (Blueprint $table) { $table->id(); $table->string('title'); $table->text('body'); $table->string('status')->default('draft'); $table->timestamps(); $table->softDeletes(); // adds a nullable "deleted_at" column });php artisan migrate -
Prepare the model
$fillableis the guest list: only these fields may be saved from a request.SoftDeletesswitches on the recycle bin.namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; class Post extends Model { use SoftDeletes; protected $fillable = ['title', 'body', 'status']; } -
One line = five routes
apiResourcecreates all five CRUD routes for you. Runphp artisan route:listto see them.use App\Http\Controllers\PostController; use Illuminate\Support\Facades\Route; Route::apiResource('posts', PostController::class);
Filling in a new form at the counter. The clerk checks it (validation), files it in a drawer (database), and hands you a ticket with your new number (id).
public function store(Request $request)
{
$data = $request->validate([
'title' => 'required|string|max:255',
'body' => 'required|string',
'status' => 'required|in:draft,published',
]);
$post = Post::create($data);
return response()->json($post, 201);
}
Send it with any API tool (Postman, Insomnia, or curl):
curl -X POST http://127.0.0.1:8000/api/posts \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"title":"Hello","body":"My first post","status":"draft"}'
{ "id": 4, "title": "Hello", "body": "My first post", "status": "draft",
"created_at": "2026-10-05T10:00:00Z", "updated_at": "2026-10-05T10:00:00Z" }
Accept: application/jsonThen, if validation fails, Laravel replies with a tidy 422 JSON list of errors instead of trying to redirect you to a web page.
Asking the waiter for the menu. You see every dish, but not the ones that were crossed out (soft-deleted).
public function index()
{
return Post::latest()->paginate(10);
}
latest() puts the newest first. paginate(10) sends 10 at a time, plus info about the next page. Use Post::all() only for small tables, because you don't want to send 100,000 posts in one reply.
Showing your ticket number at the counter: “I'd like order #42 please.” If #42 doesn't exist, you hear “not found”.
// GET /api/posts/42
public function show(Post $post)
{
return $post;
}
Wait, where's the database query? This is route model binding: because the parameter is typed Post $post, Laravel reads the 42 from the URL, finds that post for you, and returns a 404 automatically if it doesn't exist. Magic, but the good kind. ✨
“Cancel my pizza order and here's the complete new order: large, pepperoni, thin crust.” You must say everything, because the old order is thrown away.
“Same order, but add extra cheese.” You only mention the change. Everything else stays as it was.
In Laravel, both PUT and PATCH go to the same update() method. You decide how strict to be: PUT requires every field, while PATCH uses sometimes (“only check this field if it was sent”).
public function update(Request $request, Post $post)
{
// PUT → every field required. PATCH → only validate what was sent.
$rule = $request->isMethod('put') ? 'required' : 'sometimes';
$data = $request->validate([
'title' => [$rule, 'string', 'max:255'],
'body' => [$rule, 'string'],
'status' => [$rule, 'in:draft,published'],
]);
$post->update($data);
return $post;
}
PUT = Put a whole new one in its place. PATCH = a small patch on your jeans: you fix one spot, not the whole pair.
Tearing a page out of your notebook. Done. Nothing to hand back, just a nod: “it's gone.”
public function destroy(Post $post)
{
$post->delete();
return response()->noContent(); // 204
}
204 No Content means “success, and I have nothing to show you”. That's perfect for a delete.
Your computer's Recycle Bin. “Deleted” files disappear from your folders, but you can still restore them, until you empty the bin (force delete).
$post->delete(); // 🗑️ into the bin (sets deleted_at)
Post::all(); // 👀 bin items are hidden
Post::withTrashed()->get(); // 👻 show everything
Post::onlyTrashed()->get(); // 🗂️ only the bin
$post->restore(); // ↩️ back from the bin
$post->forceDelete(); // 💥 gone forever
Remember the two setup lines? $table->softDeletes() in the migration and use SoftDeletes; in the model. With those in place, your destroy() method doesn't change at all: $post->delete() now fills in deleted_at instead of removing the row.
Want endpoints to restore or permanently delete? Add two routes. ->withTrashed() lets route model binding find posts that are in the bin:
Route::post('posts/{post}/restore', [PostController::class, 'restore'])->withTrashed();
Route::delete('posts/{post}/force', [PostController::class, 'forceDelete'])->withTrashed();
public function restore(Post $post)
{
$post->restore();
return $post;
}
public function forceDelete(Post $post)
{
$post->forceDelete();
return response()->noContent();
}
- Yes: orders, users, invoices and posts. Anything someone might say “oops, bring it back!” about, or that you need to keep for records.
- No need: temporary data like login tokens or cache entries.
The complete controller
Every method from this page in one file. Copy it, run it, and test it with the playground above or Postman.
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use Illuminate\Http\Request;
class PostController extends Controller
{
public function index() // GET /api/posts
{
return Post::latest()->paginate(10);
}
public function store(Request $request) // POST /api/posts
{
$data = $request->validate([
'title' => 'required|string|max:255',
'body' => 'required|string',
'status' => 'required|in:draft,published',
]);
return response()->json(Post::create($data), 201);
}
public function show(Post $post) // GET /api/posts/{post}
{
return $post;
}
public function update(Request $request, Post $post) // PUT / PATCH
{
$rule = $request->isMethod('put') ? 'required' : 'sometimes';
$post->update($request->validate([
'title' => [$rule, 'string', 'max:255'],
'body' => [$rule, 'string'],
'status' => [$rule, 'in:draft,published'],
]));
return $post;
}
public function destroy(Post $post) // DELETE /api/posts/{post}
{
$post->delete(); // soft delete 👻
return response()->noContent();
}
}
Avoid this, do that
Post::create($request->all());
This saves whatever the user sends, unchecked. Someone could sneak in fields you never meant to allow.
$data = $request->validate([...]);
Post::create($data);
Only checked, allowed fields reach the database.
Route::get('/posts/delete/{id}', ...);
Using GET to delete is dangerous. Browsers and link previews visit GET URLs automatically, and could delete your data!
Route::delete('/posts/{post}', ...);
Use the verb that matches the action. GET only reads.
- In the playground, send PUT to
/api/posts/1but delete thebodyline first. Why do you get a 422? - Send the same request with PATCH. Why does it work now?
- Soft-delete post #1, then try GET
/api/posts/1. What status do you get, and why? - Build the real thing: a
ProductAPI withname,priceand soft deletes, using the controller above as your template.
Key takeaways
- CRUD = Create (POST), Read (GET), Update (PUT/PATCH), Delete (DELETE).
Route::apiResource()gives you all 5 routes in one line.- PUT replaces the whole record; PATCH changes only the fields you send.
- Route model binding (
Post $post) finds the record, or returns 404, for you. - Soft delete fills
deleted_atinstead of erasing, so you canrestore()later. - Always validate input, and reply with the right status: 201, 200, 204, 404 or 422.