Ilustrasi lapisan database bertumpuk dengan kaca pembesar

Di episode #69 kemarin rantai MVC-mu masih timpang: controller mengambil data dari array khayalan yang ditulis manual di kode. Di aplikasi beneran, data tinggal di database, dan ada satu komponen yang bertugas menjadi satu-satunya pintu menuju database: model.

Aturannya sederhana tapi dampaknya besar: controller tidak boleh menulis SQL langsung, view apalagi. Semua urusan database lewat model. Kenapa repot-repot? Karena begitu ada 20 controller yang butuh data menu, kamu tidak mau logika "cara mengambil menu" tersebar di 20 tempat. Satu perubahan struktur tabel berarti 20 tempat harus diubah. Dengan model, cukup satu tempat.

Analoginya: model itu seperti juru gudang di dapur restoran. Koki (controller) tidak masuk gudang sendiri dan mengobrak-abrik rak. Ia meminta ke juru gudang: "ambilkan 10 menu termurah". Juru gudang tahu persis di rak mana bahan disimpan dan bagaimana cara mengambilnya dengan benar. Di akhir artikel ini kamu akan membuat model pertamamu dan mengucapkan selamat tinggal pada SQL mentah yang berserakan.

Apa Itu Model

Model adalah class PHP di app/Models yang mewakili satu tabel database. Buat app/Models/MenuModel.php:

<?php
namespace App\Models;

use CodeIgniter\Model;

class MenuModel extends Model
{
    protected $table      = 'menu';
    protected $primaryKey = 'id';
    protected $returnType = 'array';
    protected $allowedFields = ['nama', 'harga', 'kategori'];
}

Empat properti itu adalah kontrak model dengan tabelnya: tabel mana ($table), kolom kunci apa ($primaryKey), hasil query dikembalikan sebagai apa ($returnType, array atau object), dan kolom mana saja yang boleh diisi lewat model ($allowedFields). Properti terakhir ini layak dibahas khusus, karena ia adalah fitur keamanan yang sering diremehkan.

allowedFields: Penjaga yang Sering Diremehkan

Bayangkan tabel pengguna punya kolom is_admin. Form pendaftaran hanya mengirim nama dan email, tapi penyerang iseng menambahkan field is_admin=1 ke request. Kalau model-mu menelan semua data mentah-mentah, penyerang itu resmi jadi admin. Serangan ini bernama mass assignment.

$allowedFields adalah daftar putih: hanya kolom yang terdaftar yang boleh diisi lewat insert(), update(), atau save(). Sisanya diabaikan diam-diam.

Berhenti dan tebak dulu! Model di bawah dipakai untuk menyimpan data dari form. Form dikirimi data tambahan harga=1 oleh pengguna iseng. Apa yang tersimpan ke database?

protected $allowedFields = ['nama', 'kategori'];

$model->insert([
    'nama'     => 'Nasi Goreng',
    'kategori' => 'Makanan',
    'harga'    => 1, // kiriman pengguna iseng
]);
Jawaban: hanya nama dan kategori yang tersimpan, harga diabaikan. Karena harga tidak ada di $allowedFields, model membuangnya sebelum query dijalankan. Inilah kenapa mengisi $allowedFields itu wajib, bukan opsional: ia adalah lapis pertahanan yang bekerja bahkan saat kamu lupa memvalidasi input di controller. Keamanan berlapis selalu lebih baik daripada mengandalkan satu lapis.

Membaca Data: find, findAll, where

Dengan model, membaca data jadi percakapan bahasa PHP yang mudah dibaca:

$model = new \App\Models\MenuModel();

// satu baris berdasarkan primary key
$menu = $model->find(3);

// semua baris
$semua = $model->findAll();

// baris pertama yang cocok
$murah = $model->where('harga <', 10000)->first();

// banyak baris dengan kondisi berantai
$minuman = $model->where('kategori', 'Minuman')
                  ->orderBy('harga', 'ASC')
                  ->findAll();

Perhatikan method where() bisa dirantai sebelum findAll(). Ini adalah query builder yang menempel di model: kamu menyusun query selangkah demi selangkah dengan method PHP, dan model menerjemahkannya jadi SQL yang aman. Tidak ada string SQL yang digabung manual, tidak ada risiko SQL injection dari kelalaian.

Menulis Data: insert, update, delete, save

Operasi tulisnya semudah bacanya:

$model = new \App\Models\MenuModel();

// tambah, mengembalikan id baris baru
$idBaru = $model->insert([
    'nama' => 'Soto Ayam',
    'harga' => 12000,
    'kategori' => 'Makanan',
]);

// ubah baris dengan id 3
$model->update(3, ['harga' => 17000]);

// hapus baris dengan id 3
$model->delete(3);

Berhenti dan tebak dulu! Apa isi variabel $idBaru setelah insert() di atas dijalankan, dan kenapa nilai itu berguna?

$idBaru = $model->insert([
    'nama' => 'Soto Ayam',
    'harga' => 12000,
    'kategori' => 'Makanan',
]);
Jawaban: id auto-increment dari baris yang baru dibuat. insert() mengembalikan primary key baris barunya, bukan true/false. Ini berguna misalnya setelah membuat pesanan, kamu butuh id-nya untuk membuat detail pesanan yang merujuk ke pesanan itu. Pola "insert lalu pakai id-nya" muncul di hampir setiap fitur yang melibatkan dua tabel berelasi.

Ada juga method save() yang pintar: kalau array datanya mengandung primary key, ia melakukan update, kalau tidak, ia melakukan insert. Praktis untuk form yang dipakai bersama untuk tambah dan edit.

Query Builder: SQL dengan Gaya PHP

Untuk query yang lebih bebas, kamu bisa memakai query builder langsung tanpa model:

$db = \Config\Database::connect();
$builder = $db->table('menu');

$hasil = $builder->select('nama, harga')
    ->where('kategori', 'Minuman')
    ->orderBy('harga', 'DESC')
    ->limit(5)
    ->get()
    ->getResult();

Bandingkan dengan SQL yang dihasilkannya: SELECT nama, harga FROM menu WHERE kategori = 'Minuman' ORDER BY harga DESC LIMIT 5. Sama persis dengan yang kamu pelajari di seri SQL, hanya ditulis dengan gaya PHP berantai. Hasilnya bisa diambil sebagai array object (getResult()), satu baris (getRow()), atau array asosiatif (getResultArray()).

Satu catatan dari dokumentasi: tidak semua yang kamu harapkan ada. Misalnya tidak ada method whereNull(), jadi untuk kondisi NULL tulis ->where('deleted_at IS NULL') secara eksplisit. Kalau sebuah method tidak ada, jangan mengarang, cek user guide-nya.

Model CodeIgniter juga punya dua fitur bawaan yang sayang dilewatkan. Timestamps otomatis: aktifkan $useTimestamps = true dan kolom created_at serta updated_at akan diisi sendiri setiap insert dan update. Soft delete: aktifkan $useSoftDeletes = true, dan delete() tidak lagi membuang baris, melainkan mengisi deleted_at. Persis konsep soft delete dari episode #64, sekarang otomatis.

Dua mitos perlu diluruskan. Pertama: "model cuma pembungkus query, jadi opsional". Seperti kamu lihat, model juga mengatur keamanan field, validasi, timestamps, dan soft delete. Melewatkan model berarti menulis ulang semua itu manual di setiap controller. Kedua: "query builder lebih lambat, mending SQL mentah". Selisih performanya tidak signifikan untuk kebanyakan aplikasi, sementara query builder melindungimu dari SQL injection secara otomatis. Keamanan yang gratis jangan ditolak demi optimasi yang belum dibutuhkan.

Kuis Cepat

Kuis cepat: Method mana yang tepat untuk "ambil semua menu kategori Minuman, urut dari termurah"?

Latihan: Model Menu Beneran

  1. Pastikan tabel menu ada di database-mu (kolom: id, nama, harga, kategori). Kalau belum, buat dengan migration: php spark migrate:create create_menu_table, isi method up()-nya, lalu php spark migrate.
  2. Buat MenuModel lengkap dengan $allowedFields. Sengaja jangan masukkan satu kolom ke allowedFields, lalu coba insert dengan kolom itu dan buktikan ia diabaikan.
  3. Ubah controller Menu::index() dari episode #69: ganti array khayalan dengan $model->findAll() beneran, kirim ke view yang sama.
  4. Ubah: tambahkan fitur urutkan: /menu/murah menampilkan menu di bawah 10000 urut termurah, memakai method berantai.
  5. Eksperimen: aktifkan $useTimestamps = true di model, tambahkan kolom created_at dan updated_at ke tabel, lalu insert satu baris dan lihat kolomnya terisi otomatis.
  6. Tantangan: tulis satu query builder mentah (tanpa model) untuk "3 menu termahal kategori Makanan", lalu bandingkan hasilnya dengan versi model. Mana yang lebih kamu sukai dan kenapa?

Di episode #71, Validasi Form dan Session di CodeIgniter, kita menutup dua kebutuhan wajib setiap aplikasi: memastikan input pengguna tidak ngawur sebelum masuk database, dan mengingat siapa yang sedang login lewat session. Setelah itu, di #72, semua keping menyatu dalam proyek CRUD lengkap.