🙋
Everyone keeps saying “just build a CRUD API”. What does that even mean?
💡
CRUD is the 4 things you can do with any data: Create it, Read it, Update it, Delete it. Instagram posts, shop products, to-do items: it's all CRUD.
🙋
And the “API” part?
💡
Instead of web pages, your app answers with JSON, so a mobile app or a JavaScript frontend can use it. Let's build one for blog posts. But first… play with it 👇
Play first, learn after

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.

Did you notice?

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.

The whole thing on one card

CRUD ↔ HTTP ↔ Laravel

Screenshot this table. It's 90% of every API you'll ever build.

I want to…RequestController methodSuccess reply
➕ Create a postPOST /api/postsstore()201 Created
📋 Get all postsGET /api/postsindex()200 OK
🔎 Get one postGET /api/posts/1show()200 OK (or 404)
♻️ Replace a postPUT /api/posts/1update()200 OK
✏️ Edit part of a postPATCH /api/posts/1update()200 OK
🗑️ Delete a postDELETE /api/posts/1destroy()204 No Content
🧰

Step 0 · Set up the kitchen

Four small files, then we cook.

  1. 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 --api
            

    The second command makes three things at once: a model (-m adds a migration, -c a controller), and --api fills the controller with the 5 API methods.

  2. Describe the table (migration)

    A migration is a recipe for a database table. Notice the last line: softDeletes() adds a deleted_at column. 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
            
  3. Prepare the model

    $fillable is the guest list: only these fields may be saved from a request. SoftDeletes switches 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'];
              }
            
  4. One line = five routes

    apiResource creates all five CRUD routes for you. Run php artisan route:list to see them.

    
              use App\Http\Controllers\PostController;
              use Illuminate\Support\Facades\Route;
    
              Route::apiResource('posts', PostController::class);
            
➕

Create POST

Add a brand-new post.

🏠 Real life
📝

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).

=
💻 In code

          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" }
    
💡 Always send Accept: application/json

Then, if validation fails, Laravel replies with a tidy 422 JSON list of errors instead of trying to redirect you to a web page.

📋

Get all GET

The whole list.

🏠 Real life
📖

Asking the waiter for the menu. You see every dish, but not the ones that were crossed out (soft-deleted).

=
💻 In code

          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.

🔎

Get one GET

A specific post by its id.

🏠 Real life
🎫

Showing your ticket number at the counter: “I'd like order #42 please.” If #42 doesn't exist, you hear “not found”.

=
💻 In code

          // 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. ✨

🆚

Update: PUT vs PATCH

The question every beginner asks.

🍕 PUT = replace
♻️

“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.

vs
🧀 PATCH = adjust
✏️

“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;
      }
    
Remember it like this

PUT = Put a whole new one in its place. PATCH = a small patch on your jeans: you fix one spot, not the whole pair.

🗑️

Delete DELETE

Remove a post.

🏠 Real life
✂️

Tearing a page out of your notebook. Done. Nothing to hand back, just a nod: “it's gone.”

=
💻 In code

          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.

👻

Soft delete: the recycle bin

Deleted… but not really.

🏠 Real life
🗂️

Your computer's Recycle Bin. “Deleted” files disappear from your folders, but you can still restore them, until you empty the bin (force delete).

=
💻 In code

          $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.

📄Activedeleted_at = null
🗑️Soft-deleteddeleted_at = 2026-10-05
💥Gone foreverforceDelete()
restore() sends a soft-deleted post back to “Active”. forceDelete() can't be undone.

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();
      }
    
ℹ️ When should I use soft deletes?
  • 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.
All together

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();
          }
      }
    
Common mistakes

Avoid this, do that

❌ Avoid

          Post::create($request->all());
        

This saves whatever the user sends, unchecked. Someone could sneak in fields you never meant to allow.

✅ Do

          $data = $request->validate([...]);
          Post::create($data);
        

Only checked, allowed fields reach the database.

❌ Avoid

          Route::get('/posts/delete/{id}', ...);
        

Using GET to delete is dangerous. Browsers and link previews visit GET URLs automatically, and could delete your data!

✅ Do

          Route::delete('/posts/{post}', ...);
        

Use the verb that matches the action. GET only reads.

🏋️ Try it yourself
  1. In the playground, send PUT to /api/posts/1 but delete the body line first. Why do you get a 422?
  2. Send the same request with PATCH. Why does it work now?
  3. Soft-delete post #1, then try GET /api/posts/1. What status do you get, and why?
  4. Build the real thing: a Product API with name, price and soft deletes, using the controller above as your template.
Recap

Key takeaways

Check yourself

Mini quiz