Endpoint posts kita di chapter sebelumnya masih hidup sebagai closure di file route — pola yang cepat memburuk begitu logic CRUD bertambah. Laravel menyediakan bentuk yang lebih matang: API resource controller, yaitu controller dengan nama method baku yang terpasang sempurna pada route dari Route::apiResource(). Chapter ini membangun controller CRUD penuh untuk resource posts, mulai dari kerangka hasil artisan, pemetaan endpoint-method, sampai feature test yang membuktikan seluruh siklus hidup data lewat JSON.

Membuat Kerangka dengan make:controller #

Satu perintah artisan menghasilkan kerangka controller API lengkap dengan type-hint model:

bash
php artisan make:controller Api/PostController --api --model=Post

Output yang Dihasilkan:

text
   INFO  Controller [app/Http/Controllers/Api/PostController.php] created.

Flag --api adalah pembedanya: artisan menghasilkan controller tanpa method create() dan edit() — dua method yang di web hanya bertugas menampilkan form HTML. File yang lahir berisi lima method siap isi:

php
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\Post;
use Illuminate\Http\Request;

class PostController extends Controller
{
    /**
     * Display a listing of the resource.
     */
    public function index()
    {
        //
    }

    /**
     * Store a newly created resource in storage.
     */
    public function store(Request $request)
    {
        //
    }

    /**
     * Display the specified resource.
     */
    public function show(Post $post)
    {
        //
    }

    /**
     * Update the specified resource in storage.
     */
    public function update(Request $request, Post $post)
    {
        //
    }

    /**
     * Remove the specified resource from storage.
     */
    public function destroy(Post $post)
    {
        //
    }
}

Perhatikan dua hal pada kerangka ini. Pertama, show(), update(), dan destroy() sudah menerima Post $post — route model binding aktif karena parameter route bernama {post}. Kedua, method store() dan update() menerima Request untuk membaca payload dari client.

Pemetaan Endpoint dan Method #

Nama method di atas bukan kebetulan — setiapnya terikat pada kombinasi HTTP method dan path yang didaftarkan Route::apiResource('posts', PostController::class):

| Endpoint | Method Controller | Tugas | Status Sukses | | | | | | | GET /api/v1/posts | index() | Daftar semua post | 200 | | POST /api/v1/posts | store() | Simpan post baru | 201 | | GET /api/v1/posts/{post} | show() | Satu post | 200 | | PUT/PATCH /api/v1/posts/{post} | update() | Ubah post | 200 | | DELETE /api/v1/posts/{post} | destroy() | Hapus post | 204 |

Konvensi ini berarti siapa pun yang membaca kode Laravel API kita sudah tahu di mana mencari logic tertentu tanpa membaca dokumentasi internal — struktur berbicara sendiri. Manfaatnya terasa saat onboarding anggota tim baru atau saat kita kembali ke proyek lama: pertanyaan “di mana endpoint hapus post?” terjawab oleh nama method destroy() yang posisinya baku di setiap resource controller.

Sebelum mengisi method, mari pastikan dulu bahwa kabel antara route dan controller sudah terpasang benar. Perintah php artisan route:list --path=api/v1/posts memperlihatkan kelima endpoint terdaftar lengkap dengan pasangan nama dan controller-method yang dituju — lima baris, tanpa satu pun route create atau edit, persis seperti janji flag --api. Kebiasaan memverifikasi lewat route:list setiap kali mendaftarkan resource baru juga menghemat sesi debugging ketika endpoint mendadak menjawab 404.

Mengisi Lima Method #

Sekarang kita isi kelimanya dengan implementasi CRUD yang mengikuti disiplin response dari chapter JSON Responses :

php
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\Post;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;

class PostController extends Controller
{
    public function index(): JsonResponse
    {
        return response()->json([
            'data' => Post::latest('id')->get(['id', 'title']),
        ]);
    }

    public function store(Request $request): JsonResponse
    {
        $post = Post::create($request->validate([
            'title' => 'required|string|max:100',
            'content' => 'required|string',
            'user_id' => 'required|integer|exists:users,id',
        ]));

        return response()->json($post, 201);
    }

    public function show(Post $post): Post
    {
        return $post;
    }

    public function update(Request $request, Post $post): JsonResponse
    {
        $post->update($request->validate([
            'title' => 'sometimes|string|max:100',
            'content' => 'sometimes|string',
        ]));

        return response()->json($post);
    }

    public function destroy(Post $post): Response
    {
        $post->delete();

        return response()->noContent();
    }
}

Mari kita bedah keputusan penting di dalamnya:

  1. index() membungkus koleksi di kunci data sesuai envelope kesepakatan kita, sehingga menambah meta kelak tidak mematahkan client.
  2. store() memvalidasi lebih dulu, menyimpan, lalu menjawab 201 — model Eloquent otomatis diserialisasi menjadi JSON oleh response()->json().
  3. show() cukup mengembalikan $post; Laravel mengonversi model menjadi JSON secara otomatis, sama seperti array.
  4. update() memakai rule sometimes agar PATCH benar-benar sebagian — field yang tidak dikirim tidak divalidasi dan tidak diubah.
  5. destroy() mengembalikan response()->noContent() dengan return type Response — 204 tanpa body, kontrak DELETE yang bersih.

Menguji Seluruh Siklus CRUD #

Feature test adalah cara paling jujur membuktikan endpoint ini bekerja: RefreshDatabase mengosongkan tabel tiap test, factory menyiapkan data, lalu getJson()/postJson()/patchJson()/deleteJson() memanggil endpoint persis seperti client sungguhan:

php
<?php

namespace Tests\Feature;

use App\Models\Post;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class PostApiTest extends TestCase
{
    use RefreshDatabase;

    public function test_index_mengembalikan_daftar_post(): void
    {
        $user = User::factory()->create(['name' => 'Budi', 'email' => '[email protected]']);
        Post::factory()->create(['title' => 'Belajar API Resource Controller', 'user_id' => $user->id]);

        $this->getJson('/api/v1/posts')
            ->assertOk()
            ->assertJsonCount(1, 'data')
            ->assertJsonPath('data.0.title', 'Belajar API Resource Controller');
    }

    public function test_store_menyimpan_dan_menjawab_201(): void
    {
        $user = User::factory()->create(['name' => 'Siti', 'email' => '[email protected]']);

        $this->postJson('/api/v1/posts', [
            'title' => 'Post lewat API',
            'content' => 'Dibuat dari feature test.',
            'user_id' => $user->id,
        ])
            ->assertCreated()
            ->assertJsonPath('title', 'Post lewat API');

        $this->assertDatabaseHas('posts', ['title' => 'Post lewat API']);
    }

    public function test_show_mengembalikan_satu_post(): void
    {
        $user = User::factory()->create(['name' => 'Budi', 'email' => '[email protected]']);
        $post = Post::factory()->create(['title' => 'Post tunggal', 'user_id' => $user->id]);

        $this->getJson("/api/v1/posts/{$post->id}")
            ->assertOk()
            ->assertJsonPath('title', 'Post tunggal');
    }

    public function test_show_post_tidak_ada_menjawab_404(): void
    {
        $this->getJson('/api/v1/posts/999')
            ->assertNotFound();
    }

    public function test_update_mengubah_sebagian_field(): void
    {
        $user = User::factory()->create(['name' => 'Budi', 'email' => '[email protected]']);
        $post = Post::factory()->create(['title' => 'Judul lama', 'user_id' => $user->id]);

        $this->patchJson("/api/v1/posts/{$post->id}", ['title' => 'Judul baru'])
            ->assertOk()
            ->assertJsonPath('title', 'Judul baru');

        $this->assertDatabaseHas('posts', ['id' => $post->id, 'title' => 'Judul baru']);
    }

    public function test_destroy_menghapus_dan_menjawab_204(): void
    {
        $user = User::factory()->create(['name' => 'Budi', 'email' => '[email protected]']);
        $post = Post::factory()->create(['user_id' => $user->id]);

        $this->deleteJson("/api/v1/posts/{$post->id}")
            ->assertNoContent();

        $this->assertDatabaseMissing('posts', ['id' => $post->id]);
    }
}

Output yang Dihasilkan:

text
  PASS  Tests\Feature\PostApiTest
  ✓  index mengembalikan daftar post                                     0.16s
  ✓  store menyimpan dan menjawab 201                                    0.01s
  ✓  show mengembalikan satu post                                        0.01s
  ✓  show post tidak ada menjawab 404                                    0.01s
  ✓  update mengubah sebagian field                                      0.01s
  ✓  destroy menghapus dan menjawab 204                                  0.01s

  Tests:    6 passed (15 assertions)
  Duration: 0.29s

Enam test itu memetakan satu-ke-satu dengan tabel pemetaan: assertOk() untuk 200, assertCreated() untuk 201, assertNotFound() untuk 404, dan assertNoContent() untuk 204 — assertion Laravel memang dirancang mengikuti status code. Perhatikan pula assertJsonCount(1, 'data') yang menghormati envelope kita, dan assertDatabaseHas() yang memastikan efek samping ke database sungguhan terjadi. Nilai 999 pada test 404 sengaja dipilih jauh dari ID mana pun agar kegagalan tidak pernah disebabkan kebetulan data yang ada.

Satu detail terakhir yang membuat pola ini layak dijiplak: seluruh test berjalan di atas database SQLite yang di-reset RefreshDatabase per test, jadi urutan eksekusi tidak memengaruhi hasil dan test bisa dijalankan paralel. Lima belas assertion dalam 0.29 detik adalah asuransi murah untuk lima endpoint yang akan tumbuh terus.

Controller CRUD kita kini teruji penuh dari route sampai database. Namun store() dan show() masih mengembalikan mentahan model — semua kolom tampil apa adanya. Chapter berikutnya memperkenalkan lapisan transformasi yang tepat untuk mengendalikan bentuk JSON itu: API Resource Classes .