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:
| Gejala | Tanda konkret di kode |
|---|---|
| Method membengkak | store() lebih dari 50–100 baris |
| Facade berdesakan | Mail, Storage, Log, DB dipanggil langsung dari controller |
| Logika bisnis ikut numpang | Aturan seperti “dimensi minimal 400 piksel” hidup di method controller |
| Penulisan database di dalamnya | Produk::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
// 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
// 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:
PASS Tests\Feature\ProdukStoreTest
✓ simpan produk baru dengan gambar 0.20s
Tests: 1 passed (8 assertions)
Duration: 0.27sDelapan 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
// 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
// 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
// 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
// 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:
PASS Tests\Feature\ProdukStoreTest
✓ simpan produk baru dengan gambar 0.20s
Tests: 1 passed (8 assertions)
Duration: 0.28sMengukur 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
// 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:
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.20sAssertion 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.