Controller gemuk tidak terjadi dalam satu malam. Ia tumbuh lima baris demi lima baris — “tambahkan validasi dimensi gambar deh, sekalian di controller”, “kirim email ke admin dulu sekalian” — dan suatu pagi method store() sudah berumur 57 baris dengan empat tanggung jawab berbeda. Chapter ini sengaja membangun controller gemuk yang benar-benar bekerja, menuliskan jaring pengaman berupa feature test, lalu merombaknya langkah demi langkah sampai akhirnya hanya menjadi koordinator ramping — dengan test yang sama persis tetap hijau dari awal sampai akhir.

Gejala Controller Gemuk #

Sebelum mengurus obatnya, kenali dulu penyakitnya. Empat gejala paling umum:

GejalaTanda konkret di kode
Method membengkakstore() lebih dari 50–100 baris
Facade berdesakanMail, Storage, Log, DB dipanggil langsung dari controller
Logika bisnis ikut numpangAturan seperti “dimensi minimal 400 piksel” hidup di method controller
Penulisan database di dalamnyaProduk::create() dan kawan-kawannya berbaris di method

Gejala-gejala ini bukan soal estetika. Setiap gejala menambah satu alasan controller harus diuji lewat HTTP penuh — request, session, middleware — padahal yang ingin diperiksa hanyalah satu cabang logika.

Membangun Controller Gemuk (Secara Sengaja) #

Aplikasi kedai kopi kita butuh fitur menambah produk: validasi input, proses gambar, catat produk beserta stok awal, lalu kabari admin. Berikut seluruh method store() sebelum refactor — 57 baris yang semuanya berjalan benar:

php
<?php

// app/Http/Controllers/ProdukController.php — versi gemuk (method utuh)
public function store(Request $request): RedirectResponse
{
    $data = $request->validate([
        'nama' => ['required', 'string', 'max:100'],
        'harga_cents' => ['required', 'integer', 'min:1000'],
        'gambar' => ['required', 'image', 'mimes:png', 'max:2048'],
    ]);

    // 1. Proses gambar: validasi dimensi lalu buat thumbnail lebar 300px
    $file = $request->file('gambar');
    $info = @getimagesize($file->getPathname());
    if ($info === false) {
        return back()->withErrors(['gambar' => 'Berkas bukan gambar yang valid.']);
    }
    if ($info[0] < 400) {
        return back()->withErrors(['gambar' => 'Lebar gambar minimal 400 piksel.']);
    }
    $slug = Str::slug($data['nama']);
    $path = $file->storeAs('produk', $slug.'.png', 'public');
    $source = imagecreatefromstring(file_get_contents($file->getPathname()));
    $thumb = imagescale($source, 300);
    ob_start();
    imagepng($thumb);
    $thumbBytes = ob_get_clean();
    $thumbnailPath = 'produk/thumbs/'.$slug.'.png';
    Storage::disk('public')->put($thumbnailPath, $thumbBytes);
    imagedestroy($source);
    imagedestroy($thumb);

    // 2. Catat produk dan stok awal dalam satu transaksi
    $produk = DB::transaction(function () use ($data, $path, $thumbnailPath) {
        $produk = Produk::create([
            'nama' => $data['nama'],
            'slug' => Str::slug($data['nama']),
            'harga_cents' => $data['harga_cents'],
            'gambar_path' => $path,
            'thumbnail_path' => $thumbnailPath,
        ]);

        Stok::create(['produk_id' => $produk->id, 'jumlah' => 0]);

        return $produk;
    });

    // 3. Beri tahu admin lewat email
    Mail::to('[email protected]')->send(new ProdukTersimpan($produk));

    // 4. Catat jejak audit
    Log::info('Produk baru ditambahkan', [
        'produk_id' => $produk->id,
        'nama' => $produk->nama,
    ]);

    return redirect()
        ->route('produk.index')
        ->with('status', "Produk {$produk->nama} tersimpan.");
}

Empat blok bernomor pada komentar itulah empat tanggung jawabnya: validasi, pengolahan gambar, pencatatan database, dan notifikasi. Perhatikan juga duplikasi kecil yang jadi ciri khas kode gemuk — Str::slug($data['nama']) dipanggil dua kali di dua blok berbeda. Migrasinya menyediakan tabel produks (nama, slug, harga_cents, gambar_path, thumbnail_path) dan stoks (produk_id, jumlah), plus mailable ProdukTersimpan berteks sederhana.

Jaring Pengaman: Test Dulu, Baru Refactor #

Refactor tanpa test adalah menebak. Sebelum menyentuh controller, kita tuliskan feature test yang menjelaskan perilaku yang harus tetap utuh — lengkap dengan Mail::fake() dan Storage::fake():

php
<?php

// tests/Feature/ProdukStoreTest.php — ditulis SEBELUM refactor
public function test_simpan_produk_baru_dengan_gambar(): void
{
    Mail::fake();
    Storage::fake('public');

    $response = $this->post('/produk', [
        'nama' => 'Kopi Toraja',
        'harga_cents' => 85000,
        'gambar' => UploadedFile::fake()->image('kopi-toraja.png', 600, 400),
    ]);

    $response->assertRedirectToRoute('produk.index');
    $response->assertSessionHas('status', 'Produk Kopi Toraja tersimpan.');

    $this->assertDatabaseHas('produks', [
        'nama' => 'Kopi Toraja',
        'slug' => 'kopi-toraja',
        'harga_cents' => 85000,
    ]);
    $this->assertDatabaseHas('stoks', ['produk_id' => 1, 'jumlah' => 0]);

    Storage::disk('public')->assertExists('produk/kopi-toraja.png');
    Storage::disk('public')->assertExists('produk/thumbs/kopi-toraja.png');

    Mail::assertSent(ProdukTersimpan::class, function (ProdukTersimpan $mail) {
        return $mail->hasTo('[email protected]')
            && $mail->produk->nama === 'Kopi Toraja';
    });
}

Output yang Dihasilkan:

text
  PASS  Tests\Feature\ProdukStoreTest
  ✓  simpan produk baru dengan gambar                          0.20s

  Tests:    1 passed (8 assertions)
  Duration: 0.27s

Delapan assertion menutupi redirect, flash status, dua tabel, dua berkas di disk, dan email. Teknik fake() ini dibahas tuntas di chapter File Upload Testing .

Tiga Langkah Refactor #

Langkah 1 — Validasi ke FormRequest #

php artisan make:request StoreProdukRequest menghasilkan FormRequest; rules pindah ke sana dan authorize() mengembalikan true — pembahasan lengkapnya ada di chapter Form Request Classes dan dokumentasi requests :

php
<?php

// app/Http/Requests/StoreProdukRequest.php
class StoreProdukRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'nama' => ['required', 'string', 'max:100'],
            'harga_cents' => ['required', 'integer', 'min:1000'],
            'gambar' => ['required', 'image', 'mimes:png', 'max:2048'],
        ];
    }
}

Langkah 2 — Pengolahan gambar ke action #

Seluruh blok pertama pindah ke action SimpanGambarProduk yang menerima berkas dan nama, mengembalikan dua path. Aturan dimensi kini melempar RuntimeException alih-alih membangun response — keputusan bagaimana menampilkan kesalahan kini milik pemanggil:

php
<?php

// app/Actions/SimpanGambarProduk.php — bagian inti
public function __invoke(UploadedFile $file, string $nama): array
{
    $info = @getimagesize($file->getPathname());
    if ($info === false) {
        throw new \RuntimeException('Berkas bukan gambar yang valid.');
    }
    if ($info[0] < 400) {
        throw new \RuntimeException('Lebar gambar minimal 400 piksel.');
    }

    $slug = Str::slug($nama);

    // ... buat thumbnail lebar 300 piksel dengan GD ...

    return [
        'gambar_path' => $file->storeAs('produk', $slug.'.png', 'public'),
        'thumbnail_path' => 'produk/thumbs/'.$slug.'.png',
    ];
}

Langkah 3 — Pencatatan menjadi action plus event #

Blok kedua pindah ke action CatatProdukBaru (transaksi Produk + Stok), lalu mengakhiri pekerjaannya dengan melempar event ProdukDisimpan — cara kerja event sudah kita dalami di chapter Membuat Events . Listener KirimNotifikasiAdmin yang mendengarkan event tersebut mengambil alih dua tugas terakhir: mengirim email dan mencatat log:

php
<?php

// app/Listeners/KirimNotifikasiAdmin.php — method utuh
public function handle(ProdukDisimpan $event): void
{
    Mail::to('[email protected]')->send(
        new ProdukTersimpan($event->produk),
    );

    Log::info('Produk baru ditambahkan', [
        'produk_id' => $event->produk->id,
        'nama' => $event->produk->nama,
    ]);
}

Notifikasi kini adalah efek samping yang bisa didaftarkan pengamat lain tanpa menyentuh controller — dan listener ini bahkan berantre lewat ShouldQueue bila beban emailnya membesar.

Controller Ramping #

Hasil akhirnya, controller hanya mengoordinasikan alur:

php
<?php

// app/Http/Controllers/ProdukController.php — versi ramping, berkas utuh
namespace App\Http\Controllers;

use App\Actions\CatatProdukBaru;
use App\Actions\SimpanGambarProduk;
use App\Http\Requests\StoreProdukRequest;
use Illuminate\Http\RedirectResponse;

class ProdukController extends Controller
{
    public function store(
        StoreProdukRequest $request,
        SimpanGambarProduk $simpanGambar,
        CatatProdukBaru $catatProdukBaru,
    ): RedirectResponse {
        $paths = $simpanGambar($request->file('gambar'), $request->string('nama'));

        $produk = $catatProdukBaru($request->validated(), $paths);

        return redirect()
            ->route('produk.index')
            ->with('status', "Produk {$produk->nama} tersimpan.");
    }
}

Berkas 25 baris, method 13 baris, dan satu test yang sama persis tetap hijau tanpa satu assertion pun diubah:

Output yang Dihasilkan:

text
  PASS  Tests\Feature\ProdukStoreTest
  ✓  simpan produk baru dengan gambar                          0.20s

  Tests:    1 passed (8 assertions)
  Duration: 0.28s

Mengukur Keberhasilan #

Penurunan berat dari 57 ke 13 baris terlihat di layar, tetapi keuntungan sesungguhnya adalah testability. Sebelum refactor, satu-satunya jalan masuk yang masuk akal untuk menguji logika gambar adalah request HTTP penuh. Setelah refactor, action dites langsung — perhatikan test ini tidak memakai RefreshDatabase karena sama sekali tidak menyentuh database:

php
<?php

// tests/Feature/SimpanGambarProdukTest.php — test pertama dari dua test
public function test_simpan_gambar_bisa_dites_tanpa_http(): void
{
    Storage::fake('public');

    $file = UploadedFile::fake()->image('kopi-toraja.png', 600, 400);

    $simpanGambar = $this->app->make(SimpanGambarProduk::class);

    $paths = $simpanGambar($file, 'Kopi Toraja');

    Storage::disk('public')->assertExists($paths['gambar_path']);
    Storage::disk('public')->assertExists($paths['thumbnail_path']);

    $thumb = Storage::disk('public')->get($paths['thumbnail_path']);
    $this->assertSame(300, getimagesizefromstring($thumb)[0]);
}

Output yang Dihasilkan:

text
  PASS  Tests\Feature\SimpanGambarProdukTest
  ✓  simpan gambar bisa dites tanpa http                      0.11s
  ✓  gambar terlalu kecil ditolak                              0.01s

  Tests:    2 passed (5 assertions)
  Duration: 0.20s

Assertion getimagesizefromstring() membuktikan thumbnail benar-benar 300 piksel lebar — pemeriksaan yang dulu mustahil dilakukan tanpa membongkar response HTTP. Suite lengkap aplikasi sandbox kini berisi 14 test: refactor menambah cakupan, bukan sekadar memindahkan kode.

Kita sudah menata kelas, struktur folder, konfigurasi, dan controller satu per satu. Chapter penutup Section ini menarik benang merahnya menjadi satu praktik yang membuat aplikasi Laravel tetap sehat seiring bertambahnya usia.