Mengelola satu jenis data — misalnya post — hampir selalu berarti tujuh kegiatan yang sama: tampilkan daftar, tampilkan form baru, simpan, tampilkan detail, tampilkan form ubah, perbarui, dan hapus. Menulis tujuh route plus tujuh method satu per satu berulang setiap kali kita menambah entitas baru. Laravel meringkas pekerjaan tersebut menjadi resource controller: satu perintah generator menghasilkan tujuh method standar, dan satu baris Route::resource mendaftarkan tujuh route-nya. Chapter ini membangun CRUD post sungguhan di atas SQLite sampai lolos feature test.

Membuat Controller –resource #

Karena PostController versi dua method dari chapter sebelumnya sudah tidak relevan, hapus dulu berkasnya lalu jalankan generator dengan flag --resource:

bash
rm app/Http/Controllers/PostController.php
php artisan make:controller PostController --resource

Output yang Dihasilkan:

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

Generator kini menghasilkan tujuh method kosong dengan nama tetap:

php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class PostController extends Controller
{
    public function index()
    {
        //
    }

    public function create()
    {
        //
    }

    public function store(Request $request)
    {
        //
    }

    public function show(string $id)
    {
        //
    }

    public function edit(string $id)
    {
        //
    }

    public function update(Request $request, string $id)
    {
        //
    }

    public function destroy(string $id)
    {
        //
    }
}

Nama method tersebut adalah konvensi yang diikat Laravel, bukan sekadar gaya — Route::resource mencocokkan nama-nama ini dengan HTTP method dan URI. Pemetaan tujuh aksinya sebagai berikut:

HTTP methodURIMethodTugas
GET/postsindex()Daftar seluruh post
GET/posts/createcreate()Form post baru
POST/postsstore()Simpan post baru
GET/posts/{post}show()Detail satu post
GET/posts/{post}/editedit()Form ubah post
PUT/PATCH/posts/{post}update()Perbarui post
DELETE/posts/{post}destroy()Hapus post

Satu Baris Route::resource #

Ganti seluruh route posts lama dengan satu baris di routes/web.php:

php
<?php

use App\Http\Controllers\PostController;
use Illuminate\Support\Facades\Route;

Route::resource('posts', PostController::class);

Argumen pertama adalah nama resource tanpa garis miring; Laravel membentuk URI dan nama route dari nama tersebut. Bentuk jamak posts menghasilkan parameter {post} tunggal di route detail — Laravel mengubah nama resource menjadi bentuk singular untuk parameter, kebiasaan yang sebaiknya diikuti juga pada nama model Post agar route model binding berjalan mulus tanpa konfigurasi. Kita periksa hasilnya:

bash
php artisan route:list --except-vendor --path=posts

Output yang Dihasilkan:

text
  GET|HEAD        posts ................... posts.index › PostController@index
  POST            posts ................... posts.store › PostController@store
  GET|HEAD        posts/create .......... posts.create › PostController@create
  GET|HEAD        posts/{post} .............. posts.show › PostController@show
  PUT|PATCH       posts/{post} .......... posts.update › PostController@update
  DELETE          posts/{post} ........ posts.destroy › PostController@destroy
  GET|HEAD        posts/{post}/edit ......... posts.edit › PostController@edit

                                                            Showing [7] routes

Perhatikan dua hal pada output di atas. Pertama, setiap route otomatis bernama mengikuti pola posts.aksi (posts.index, posts.store, dst), sehingga kita langsung bisa memanggil route('posts.index'). Kedua, route posts/create muncul sebelum posts/{post} — Laravel mengurutkan pola statis lebih dulu agar /posts/create tidak tertelan pola dinamis {post}.

Menyempitkan dengan only dan except #

Aplikasi nyata tidak selalu butuh ketujuh aksi. Method ->only() memilih sebagian route, sedangkan ->except() membuang sebagian:

php
Route::resource('posts', PostController::class)->only(['index', 'show']);

Output yang Dihasilkan:

text
  GET|HEAD       posts .................... posts.index › PostController@index
  GET|HEAD       posts/{post} ............... posts.show › PostController@show

                                                            Showing [2] routes

Bentuk hanya-baca seperti di atas cocok untuk data yang diubah lewat admin panel atau seeder. Jalankan route:list setiap kali menyempitkan resource — daftar yang tampil adalah kebenaran akhirnya, bukan daftar yang kita niatkan.

Mini-CRUD yang Benar-Benar Jalan #

Sekarang kita isi method-nya dengan implementasi nyata di atas tabel SQLite. Buat dulu migration dan modelnya — detail keduanya dibahas penuh di Section Database dan Migrations serta Eloquent ORM:

php
Schema::create('posts', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->text('content');
    $table->timestamps();
});

Implementasi controller mengikuti pola koordinator dari chapter Konsep Controller:

php
<?php

namespace App\Http\Controllers;

use App\Models\Post;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;

class PostController extends Controller
{
    public function index(): View
    {
        return view('posts.index', ['posts' => Post::latest()->get()]);
    }

    public function create(): View
    {
        return view('posts.create');
    }

    public function store(Request $request): RedirectResponse
    {
        $validated = $request->validate([
            'title' => 'required|string|max:100',
            'content' => 'required|string',
        ]);

        $post = Post::create($validated);

        return redirect()->route('posts.show', $post)
            ->with('status', 'Post berhasil disimpan.');
    }

    public function show(Post $post): View
    {
        return view('posts.show', ['post' => $post]);
    }

    public function edit(Post $post): View
    {
        return view('posts.edit', ['post' => $post]);
    }

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

        $post->update($validated);

        return redirect()->route('posts.show', $post)
            ->with('status', 'Post berhasil diperbarui.');
    }

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

        return redirect()->route('posts.index')
            ->with('status', 'Post berhasil dihapus.');
    }
}

Dua pola penting ada di sini. Signature show(Post $post) memanfaatkan route model binding — Laravel mencari post berdasarkan {post} di URI dan mengirimkan objeknya sebagai argumen (kita bahas tuntas di chapter Route Model Binding). Method $request->validate() memeriksa input sebelum data disentuh database; gagal validasi otomatis me-redirect pengirim kembali — pembahasan lengkapnya menunggu Section Validasi dan Form.

Alur CRUD tersebut kita kunci dengan feature test yang menelusuri ketujuh aksi secara berurutan:

php
<?php

namespace Tests\Feature;

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

class PostCrudTest extends TestCase
{
    use RefreshDatabase;

    public function test_alur_crud_lengkap(): void
    {
        $this->get('/posts')->assertOk()->assertSee('Daftar Post');
        $this->get('/posts/create')->assertOk()->assertSee('Tulis Post Baru');

        $this->post('/posts', [
            'title' => 'Belajar Resource Controller',
            'content' => 'Satu baris Route::resource menangani tujuh aksi.',
        ])->assertRedirect(route('posts.show', Post::first()));

        $post = Post::first();

        $this->get("/posts/{$post->id}")
            ->assertOk()
            ->assertSee('Belajar Resource Controller');

        $this->get("/posts/{$post->id}/edit")->assertOk()->assertSee('Ubah Post');

        $this->put("/posts/{$post->id}", [
            'title' => 'Belajar Resource Controller (revisi)',
            'content' => 'Konten diperbarui.',
        ])->assertRedirect("/posts/{$post->id}");

        $this->delete("/posts/{$post->id}")->assertRedirect('/posts');

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

Output yang Dihasilkan:

text
  PASS  Tests\Feature\PostCrudTest
  ✓  alur crud lengkap                                                   0.17s  

  Tests:    1 passed (17 assertions)
  Duration: 0.25s

Tujuh belas assertion dalam satu test mencakup seluruh siklus: dua halaman terbuka, data tersimpan di SQLite, detail dan form ubah ditemukan, revisi tersimpan, dan baris hilang setelah DELETE. CRUD utuh selesai dalam satu berkas controller dan satu baris Route::resource, persis seperti janji dokumentasi controllers .

Di ujung lain spektrum ini ada controller yang justru terlalu kecil untuk tujuh method: endpoint dengan satu tugas tunggal. Chapter Single Action Controller berikutnya membahas bentuk paling ringkasnya.