September 27, 2026
WHMCS Hooks & Module Development.
Membedah cara memperluas WHMCS tanpa memodifikasi core: sistem hook, addon module, provisioning module, gateway dan registrar, Local API, Capsule, logging, serta praktik yang aman saat upgrade.
WHMCS adalah platform billing dan otomasi untuk bisnis hosting. Bawaannya sudah bisa menagih, membuat invoice, dan memanggil control panel populer. Tapi bisnis hosting sungguhan hampir selalu butuh perilaku yang tidak ada di kotak: VPS yang diprovisikan lewat API internal, konfirmasi transfer bank manual, validasi hostname sebelum checkout, atau notifikasi ke WhatsApp ketika ada tiket baru.
Kode inti WHMCS sebagian besar dienkripsi dan tidak dimaksudkan untuk diubah. Semua kustomisasi harus lewat titik ekstensi resmi. Catatan ini membahas titik-titik itu, cara kerjanya, dan pelajaran dari membangun puluhan hook serta modul kustom di proyek WHMCS Custom Modules & Hooks .
Semua contoh di sini ditulis ulang dan disederhanakan dari kode kustom saya sendiri, dan nama modul, tabel, serta endpoint sudah diganti placeholder. Rujukan API diambil dari dokumentasi resmi di developers.whmcs.com.
Peta Titik Ekstensi WHMCS
Sebelum menulis kode, pahami dulu “wadah” apa saja yang disediakan WHMCS:
| Jenis | Lokasi | Kapan dipakai |
|---|---|---|
| Hook | includes/hooks/*.php atau modules/<tipe>/<nama>/hooks.php | Menyisipkan logika saat suatu event terjadi (client dibuat, invoice dibayar, halaman dirender) |
| Addon module | modules/addons/<nama>/ | Fitur mandiri dengan halaman admin/client sendiri, tabel sendiri, dan konfigurasi |
| Provisioning (server) module | modules/servers/<nama>/ | Menghubungkan produk ke sistem eksternal: create, suspend, terminate, upgrade |
| Registrar module | modules/registrars/<nama>/ | Registrasi, transfer, perpanjangan domain, dan manajemen nameserver |
| Payment gateway | modules/gateways/<nama>.php + callback/ | Menerima pembayaran dan menandai invoice lunas |
| API | localAPI() (internal) atau HTTP API (eksternal) | Menjalankan aksi WHMCS dari kode atau dari sistem lain |
Aturan praktisnya: kalau yang kamu butuhkan adalah reaksi terhadap event, pakai hook. Kalau butuh UI dan data sendiri, pakai addon. Kalau butuh siklus hidup layanan, pakai provisioning module.
Sistem Hook
Anatomi add_hook
Hook didaftarkan dengan fungsi global add_hook() yang menerima tiga argumen: nama hook point, prioritas, dan fungsi yang dijalankan (nama fungsi atau closure).
<?php
// includes/hooks/validasi_hostname.php
add_hook('ShoppingCartValidateProductUpdate', 1, function (array $vars) {
$errors = [];
if (isset($vars['hostname']) && str_contains($vars['hostname'], '_')) {
$errors[] = Lang::trans('orderErrorServerHostnameInvalid');
}
return $errors;
});
Contoh di atas diambil dari hook nyata yang saya pakai untuk menolak hostname VPS dengan underscore (karakter _ tidak valid di hostname DNS). Tiga hal yang terjadi di sini:
- WHMCS memuat semua file di
includes/hooks/pada setiap request. Tidak perlu registrasi, cukup taruh filenya. $varsberisi data kontekstual dari hook point. UntukShoppingCartValidateProductUpdateisinya adalah variabel REQUEST.- Nilai kembalian punya arti. Hook point ini menerima string (satu error) atau array string (banyak error), dan WHMCS akan menampilkannya ke pelanggan serta membatalkan update keranjang.
Dokumentasi resmi juga menyebut trik kecil yang berguna: file hook yang namanya diawali underscore (_) tidak dieksekusi. Ini cara aman menonaktifkan hook sementara tanpa menghapusnya. Jauh lebih baik daripada mengomentari seluruh isi file, atau mengganti nama hook point jadi AfterCronJob_ supaya “tidak pernah terpanggil” (pernah saya lakukan, dan membingungkan orang yang membacanya belakangan).
Prioritas
Setiap hook wajib punya prioritas berupa integer. Menurut dokumentasi, prioritas menentukan urutan eksekusi saat beberapa hook terdaftar di hook point yang sama. Dalam praktiknya, angka kecil dijalankan lebih dulu. Ini penting ketika dua hook saling bergantung, misalnya hook addon yang mengubah variabel template dan hook lain di includes/hooks/ yang membaca hasilnya:
// Hook di addon: jalan belakangan agar melihat hasil hook lain
add_hook('ClientAreaPageViewInvoice', 50, function ($vars) { /* ... */ });
// Hook umum: jalan lebih dulu
add_hook('ClientAreaPageViewInvoice', 1, function ($vars) { /* ... */ });
Jangan bergantung pada urutan kalau tidak perlu. Kalau terpaksa, tulis alasannya di komentar, karena urutan antar-file tidak terlihat dari satu file saja.
Nilai Kembalian Berbeda di Tiap Hook Point
Ini sumber bug paling umum. Setiap hook point punya kontrak kembalian sendiri, dan satu-satunya sumber kebenaran adalah halaman Hook Reference:
| Hook point | Kembalian yang diharapkan |
|---|---|
ClientAreaPage | Array key/value berisi variabel template tambahan |
ShoppingCartValidateCheckout, ShoppingCartValidateProductUpdate | String (satu error) atau array string (banyak error) |
TicketOpenValidation | Satu string sebagai pesan validasi |
AfterShoppingCartCheckout | Tidak ada, murni notifikasi |
ClientAreaPrimarySidebar | Tidak ada; kamu memodifikasi objek menu yang diterima |
Contoh hook sidebar. Objek MenuItem dimodifikasi di tempat, bukan dikembalikan:
use WHMCS\View\Menu\Item as MenuItem;
add_hook('ClientAreaPrimarySidebar', 99, function (MenuItem $sidebar) {
// Hapus menu yang tidak relevan
$sidebar->removeChild('Client Contacts');
$parent = $sidebar->getChild('Services') ?: $sidebar;
$parent->addChild('Registrasi Domain', [
'label' => 'Registrasi Domain',
'uri' => 'domain-search.php',
'order' => 22,
]);
});
Pelajaran dari Hook yang Salah
Berikut pola hook validasi tiket lama saya yang bekerja, tapi dengan cara yang salah:
// JANGAN ditiru
add_hook('TicketOpenValidation', 99, function ($vars) {
$ticket = Capsule::table('tbltickets')
->where('status', 'Open')
->where('title', $vars['subject'])
->first();
function containsAny($haystack, array $needles): bool { /* ... */ }
if (containsAny($ticket->title, [...]) && $ticket) {
redir([...], 'form-referral-request.php');
exit();
}
});
Ada tiga masalah di sini:
- Fungsi bernama di dalam closure. PHP mendeklarasikan
containsAny()secara global saat closure dijalankan. Kalau hook ini terpanggil dua kali dalam satu request, PHP akan melempar fatal error “Cannot redeclare function”. Deklarasikan helper di luar closure dengan prefix unik, atau pakai closure lokal. - Akses properti sebelum cek null.
$ticket->titledibaca sebelum$ticketdipastikan ada. redir()+exit()di hook validasi. KontrakTicketOpenValidationadalah mengembalikan string error. Memotong eksekusi denganexit()melewati semua hook lain di hook point yang sama dan membuat perilaku sulit dilacak.
Versi yang benar:
function acme_ticket_is_duplicate_referral(string $subject): bool
{
$needles = ['Request Referral', 'Referral Request'];
foreach ($needles as $n) {
if (stripos($subject, $n) !== false) {
return Capsule::table('tbltickets')
->where('status', 'Open')
->where('title', $subject)
->exists();
}
}
return false;
}
add_hook('TicketOpenValidation', 1, function (array $vars) {
if (acme_ticket_is_duplicate_referral($vars['subject'] ?? '')) {
return 'Permintaan referral Anda masih diproses. Silakan cek tiket yang sudah ada.';
}
});
Hook Mahal Memperlambat Semua Halaman
Hook di includes/hooks/ dimuat di setiap request, dan hook pada hook point yang sering dipanggil (ClientAreaPage, AdminAreaPage, ClientAreaHeadOutput) ikut jalan di setiap render halaman. Query berat, panggilan HTTP ke API eksternal, atau loop di sini langsung terasa di waktu muat halaman seluruh client area.
Hook AfterCronJob juga punya jebakannya sendiri. Ia jalan setiap kali cron
WHMCS selesai. Kalau di dalamnya kamu mengirim notifikasi untuk setiap item to-do yang belum selesai tanpa menandai mana yang sudah dikirim, pengguna akan menerima notifikasi yang sama berulang kali. Simpan penanda “sudah diproses” (kolom atau tabel mod_*), atau lebih baik lagi, buat kerja itu idempoten
.
Addon Module
Addon module adalah “aplikasi mini” di dalam WHMCS. Strukturnya mengikuti konvensi nama: semua fungsi diberi prefix nama folder.
modules/addons/acme_confirm/
├── acme_confirm.php # config, activate, deactivate, output, clientarea
├── hooks.php # hook milik addon ini
├── lang/english.php
└── templates/
_config: Metadata dan Field Pengaturan
function acme_confirm_config(): array
{
return [
'name' => 'Payment Confirmation',
'description' => 'Konfirmasi transfer manual oleh pelanggan.',
'author' => 'Acme',
'language' => 'english',
'version' => '1.2.0',
'fields' => [
'max_file_mb' => [
'FriendlyName' => 'Ukuran file maks (MB)',
'Type' => 'text',
'Default' => '2',
],
'notify_admin' => [
'FriendlyName' => 'Notifikasi admin',
'Type' => 'yesno',
],
],
];
}
Tipe field yang didukung: text, password, yesno, textarea, dropdown, dan radio. Nilainya otomatis diteruskan ke fungsi _output dan _clientarea di $vars.
_activate dan _deactivate: Skema Database
Dokumentasi resmi mencontohkan pembuatan tabel lewat Capsule::schema() di dalam try/catch, dan mengembalikan array status (success, error, atau info) plus description:
use WHMCS\Database\Capsule;
function acme_confirm_activate(): array
{
try {
if (!Capsule::schema()->hasTable('mod_acme_confirm')) {
Capsule::schema()->create('mod_acme_confirm', function ($table) {
$table->increments('id');
$table->unsignedInteger('invoice_id')->unique();
$table->string('file');
$table->timestamps();
});
}
return ['status' => 'success', 'description' => 'Tabel dibuat.'];
} catch (\Exception $e) {
return ['status' => 'error', 'description' => $e->getMessage()];
}
}
function acme_confirm_deactivate(): array
{
// Pertimbangkan baik-baik: menghapus tabel = menghapus data pelanggan.
return ['status' => 'success', 'description' => 'Addon dinonaktifkan; data dipertahankan.'];
}
Perhatikan hasTable() dan unique() pada invoice_id. Aktivasi ulang tidak boleh gagal atau menggandakan data. Soal deaktivasi, contoh resmi memang melakukan dropIfExists(). Tapi untuk addon yang menyimpan data bisnis (bukti transfer, log), saya memilih tidak menghapus tabel saat deaktivasi, karena admin sering menonaktifkan addon sementara untuk debugging.
_output dan _clientarea
_output merender halaman admin di addonmodules.php?module=<nama>. Satu hal yang mudah terlewat: dokumentasi menegaskan output harus di-echo, bukan di-return.
function acme_confirm_output(array $vars): void
{
$link = $vars['modulelink']; // addonmodules.php?module=acme_confirm
$action = $_GET['action'] ?? 'list';
echo match ($action) {
'view' => acme_confirm_render_detail((int) ($_GET['id'] ?? 0), $link),
default => acme_confirm_render_list($link),
};
}
_clientarea justru kebalikannya: ia me-return array berisi pagetitle, breadcrumb, templatefile, requirelogin, forcessl, dan vars. Halamannya diakses di index.php?m=<nama>.
function acme_confirm_clientarea(array $vars): array
{
return [
'pagetitle' => 'Konfirmasi Pembayaran',
'breadcrumb' => ['index.php?m=acme_confirm' => 'Konfirmasi'],
'templatefile' => 'form',
'requirelogin' => true,
'vars' => ['maxMb' => (int) $vars['max_file_mb']],
];
}
Untuk addon yang besar, saya memindahkan logika keluar dari file utama ke folder app/ dengan autoloader sendiri, dan fungsi _config/_output hanya menjadi entry point tipis yang mendelegasikan ke kelas. Hasilnya bisa diuji dan jauh lebih rapi daripada satu file PHP ribuan baris.
hooks.php Milik Addon
Addon bisa membawa hooks.php sendiri. Keuntungannya, hook ikut terbawa bersama modulnya (dan ikut hilang saat foldernya dihapus). Contoh nyata: addon konfirmasi pembayaran saya membersihkan data konfirmasi dan file bukti saat invoice dibatalkan.
add_hook('InvoiceCancelled', 1, function (array $vars) {
$invoiceId = (int) ($vars['invoiceid'] ?? 0);
if ($invoiceId <= 0) {
return;
}
$row = Capsule::table('mod_acme_confirm')->where('invoice_id', $invoiceId)->first();
if (!$row) {
return;
}
Capsule::table('mod_acme_confirm')->where('id', $row->id)->delete();
$path = ACME_UPLOAD_DIR . '/' . basename($row->file);
if (is_file($path)) {
unlink($path);
}
});
Perhatikan basename() dan cek is_file(). Versi awal saya memanggil unlink() langsung dengan nama file dari database, yang akan memunculkan warning bila file sudah tidak ada dan, lebih buruk, berisiko path traversal kalau nama file pernah bisa dikendalikan pengguna.
Provisioning (Server) Module
Provisioning module menjembatani produk di WHMCS dengan sistem nyata yang menyediakan layanan. Nama modul harus satu kata, huruf kecil dan angka, diawali huruf, dan unik. Filenya ada di modules/servers/<nama>/<nama>.php.
Siklus Hidup
| Fungsi | Dipicu ketika |
|---|---|
_CreateAccount | Order dibayar atau admin menekan “Create” |
_SuspendAccount | Layanan jatuh tempo (overdue) atau suspend manual |
_UnsuspendAccount | Invoice overdue dibayar |
_TerminateAccount | Layanan sangat lama overdue atau terminate manual |
_Renew | Invoice perpanjangan dibayar |
_ChangePackage | Upgrade/downgrade produk atau opsi |
_ChangePassword | Pelanggan mengganti password layanan |
_TestConnection | Tombol “Test Connection” di konfigurasi server |
Semua fungsi siklus hidup mengikuti kontrak yang sama: kembalikan string 'success' bila berhasil, atau string pesan error bila gagal. WHMCS menampilkan pesan itu ke admin dan tidak mengubah status layanan.
_MetaData dan _ConfigOptions
function vpsbridge_MetaData(): array
{
return [
'DisplayName' => 'VPS Bridge',
'APIVersion' => '1.1',
'RequiresServer' => true,
'DefaultSSLPort' => '443',
'AdminSingleSignOnLabel' => 'Login ke Panel',
];
}
function vpsbridge_ConfigOptions(): array
{
return [
'RAM (MB)' => ['Type' => 'text', 'Size' => '10', 'Default' => '1024'],
'CPU Core' => ['Type' => 'text', 'Size' => '5', 'Default' => '1'],
'Disk (GB)' => ['Type' => 'text', 'Size' => '10', 'Default' => '20'],
'Suspend Action' => [
'Type' => 'dropdown',
'Options' => 'None,Add To-Do Item,Create Support Ticket',
],
];
}
Yang sering disalahpahami: ConfigOptions mendefinisikan pengaturan per-produk (tab Module Settings di konfigurasi produk). Nilainya sampai ke fungsi lain sebagai $params['configoption1'], $params['configoption2'], dan seterusnya, berdasarkan urutan. Maksimalnya 24. Artinya, menyisipkan opsi baru di tengah array akan menggeser semua indeks, dan setiap produk yang sudah ada tiba-tiba membaca nilai yang salah. Selalu tambahkan opsi baru di akhir.
Jangan tertukar dengan $params['configoptions'] (jamak, tanpa angka). Itu berisi Configurable Options yang dipilih pelanggan saat order, diindeks berdasarkan nama.
_ConfigOptions adalah satu-satunya fungsi yang tidak menerima $params, karena ia dipanggil di level produk, bukan layanan.
Isi $params
Semua fungsi lain menerima $params yang kaya konteks: serviceid, userid, pid, domain, username, password, clientsdetails (array data pemilik), customfields, configoptions, dan kredensial server (serverip, serverhostname, serverusername, serverpassword, serversecure, serverport).
Contoh SuspendAccount yang Tangguh
Modul VPS saya berbicara dengan API manajemen internal. Masalahnya, API itu kadang tidak bisa dijangkau. Pola yang akhirnya dipakai: coba otomatis, dan kalau gagal, jatuh ke jalur manual yang terlacak, dan jangan pernah gagal diam-diam.
function vpsbridge_SuspendAccount(array $params): string
{
$serviceId = (int) $params['serviceid'];
try {
$vm = vpsbridge_api('GET', 'server/finder', ['query' => ['q' => vpsbridge_remote_ip($params)]]);
vpsbridge_api('POST', "vm/{$vm['id']}/status/stop");
logModuleCall('vpsbridge', __FUNCTION__, $params, 'stopped', $vm, [
$params['serverpassword'], $params['password'],
]);
return 'success';
} catch (\Throwable $e) {
logModuleCall('vpsbridge', __FUNCTION__, $params, $e->getMessage(), $e->getTraceAsString(), [
$params['serverpassword'], $params['password'],
]);
// Fallback: buat to-do agar staf menangani secara manual
Capsule::table('tbltodolist')->insert([
'date' => date('Y-m-d'),
'title' => 'Manual Suspend Required',
'description' => "Service ID #{$serviceId}: " . $e->getMessage(),
'status' => 'Pending',
'duedate' => date('Y-m-d'),
]);
return 'API error: ' . $e->getMessage();
}
}
Beberapa keputusan desain di sini:
- HTTP client membedakan jenis error. Di modul aslinya, fungsi pemanggil API melempar exception berbeda untuk respons non-JSON, error 4xx dengan pesan dari API, dan error 5xx. Pesan untuk admin jadi spesifik (“Response api error (502)”) dan bukan sekadar “gagal”.
- Kembalikan pesan error, jangan
'success'palsu. Kalau modul mengembalikan'success'padahal VM tidak berhenti, WHMCS menandai layanan sebagai Suspended sementara pelanggan masih bisa memakainya. - Kredensial masuk parameter
$replaceVarsagar tersensor di log (lihat bagian logging).
Custom Action di Client Area
Tombol aksi untuk pelanggan (Start/Stop VPS, Reset Network) didefinisikan lewat _ClientAreaCustomButtonArray, atau _ClientAreaAllowedFunctions untuk fungsi yang boleh dipanggil tapi tidak tampil sebagai tombol. Setiap fungsi yang diekspos ke pelanggan adalah endpoint publik. Validasi status layanan, dan tambahkan pembatasan laju. Contohnya, modul saya membatasi aksi “stop” satu kali per beberapa menit per layanan, agar pelanggan tidak bisa membanjiri hypervisor
dengan perintah start/stop berulang.
Satu catatan: pembatasan seperti itu sebaiknya disimpan di server (tabel atau cache), bukan di cookie pelanggan. Cookie bisa dihapus dan hanya cocok sebagai lapisan UX.
Registrar dan Payment Gateway, Singkat
Registrar module menangani registrasi, transfer, perpanjangan, nameserver, WHOIS, DNS, EPP code, lock, dan sinkronisasi tanggal kedaluwarsa. Konvensi errornya berbeda dari provisioning: fungsi registrar mengembalikan array, dan kegagalan ditandai dengan key error:
return ['error' => 'Domain name not found'];
Sukses diasumsikan bila tidak ada error. Pesan ini hanya tampil ke admin.
Payment gateway punya dua sisi: file modul (konfigurasi dan tombol bayar) dan file callback yang menerima notifikasi dari penyedia pembayaran. Di callback, dokumentasi menyediakan helper yang wajib dipakai berurutan:
$gateway = getGatewayVariables('acmepay');
if (!$gateway['type']) {
die('Module Not Activated');
}
// 1. Verifikasi tanda tangan dari penyedia (spesifik per gateway) — WAJIB
// 2. Validasi invoice (menghentikan eksekusi bila tidak valid)
$invoiceId = checkCbInvoiceID($_POST['invoice_id'], $gateway['name']);
// 3. Tolak transaksi yang sudah pernah dicatat (idempotensi)
checkCbTransID($_POST['transaction_id']);
logTransaction($gateway['name'], $_POST, 'Success');
addInvoicePayment($invoiceId, $_POST['transaction_id'], $_POST['amount'], 0.00, 'acmepay');
checkCbTransID() adalah garis pertahanan terhadap callback ganda. Penyedia pembayaran hampir selalu mengirim ulang notifikasi bila tidak mendapat respons 200 tepat waktu. Tanpa cek ini, satu pembayaran bisa tercatat dua kali.
Local API vs External API
WHMCS punya satu set perintah API (AddOrder, OpenTicket, SendEmail, ModuleSuspend, dan ratusan lainnya) yang bisa dipanggil lewat dua jalur:
| Local API | External API | |
|---|---|---|
| Cara panggil | localAPI($command, $values, $adminUser) | HTTP POST ke endpoint API WHMCS |
| Dipakai dari | Hook dan modul (kode yang jalan di dalam WHMCS) | Sistem lain: aplikasi, bot, integrasi |
| Autentikasi | Tidak perlu; berjalan di konteks WHMCS | identifier + secret (API credentials); admin terkait butuh permission API Access |
| Overhead | Pemanggilan fungsi PHP | Round-trip HTTP |
$result = localAPI('OpenTicket', [
'clientid' => $params['clientsdetails']['userid'],
'deptid' => 2,
'subject' => 'Service Suspension',
'message' => "Service ID #{$params['serviceid']} requires suspension",
'priority' => 'Low',
]);
if ($result['result'] !== 'success') {
logActivity('OpenTicket gagal: ' . ($result['message'] ?? 'unknown'));
}
Dua hal penting:
- Selalu cek
$result['result'].localAPItidak melempar exception saat perintah gagal. Ia mengembalikan array denganresult=errordanmessage. Kode lama saya memanggillocalAPI(...)tanpa memeriksa hasilnya sama sekali, sehingga tiket yang gagal dibuat tidak pernah ketahuan. - Argumen admin username. Sejak WHMCS 7.2 argumen ketiga bersifat opsional. Di kode lama sering terlihat angka
1di posisi itu. Lebih jelas untuk menghilangkannya, atau mengisinya dengan username admin khusus otomasi, supaya jejak di log aktivitas terbaca.
Sebagai prinsip: gunakan localAPI daripada menulis langsung ke tabel tbl* untuk aksi yang punya efek samping (membuat tiket, menambah pembayaran, mengirim email). API menjalankan hook terkait, mengirim email, dan menjaga konsistensi. INSERT langsung ke tbltickets melewati semuanya.
Capsule: Laravel Database di Dalam WHMCS
Sejak WHMCS 6.0, akses database dilakukan lewat Capsule, lapisan abstraksi yang dibangun di atas komponen database Laravel (Illuminate). Tiga pintu masuknya:
use WHMCS\Database\Capsule;
Capsule::table('tblhosting'); // query builder
Capsule::schema(); // manajemen skema (create/alter/drop)
Capsule::connection(); // transaksi dan akses PDO
Contoh transaksi . Semua atau tidak sama sekali:
Capsule::connection()->transaction(function ($db) use ($invoiceId, $file) {
$db->table('mod_acme_confirm')->insert([
'invoice_id' => $invoiceId,
'file' => $file,
'created_at' => date('Y-m-d H:i:s'),
]);
$db->table('mod_acme_confirm_log')->insert([
'invoice_id' => $invoiceId,
'action' => 'submitted',
]);
});
Aturan yang saya pegang:
- Query builder sudah melakukan escaping parameter. Jangan pernah menyusun SQL dengan konkatenasi string. Fungsi lawas seperti
select_query(),update_query(), daninsert_query()masih ada, tapi dokumentasi menyarankan untuk tidak memakainya. - Tabel kustom diberi prefix
mod_. Jangan menambah kolom ke tabeltbl*milik WHMCS. Upgrade bisa mengubah skema tabel inti, dan kolom tambahan bisa hilang atau bentrok. Simpan data tambahan di tabel sendiri dengan foreign key ke ID WHMCS. - Bungkus operasi tulis dengan
try/catch, sesuai rekomendasi dokumentasi. - Membaca tabel inti boleh, menulis sebisa mungkin lewat API. Membaca
tblhostinguntuk laporan itu wajar; mengubahtblinvoices.statussecara langsung (seperti hookInvoiceCreatedlama saya yang memaksa invoice jadi Draft) melewati logika bisnis WHMCS dan hampir pasti menimbulkan efek samping.
Logging: logModuleCall vs logActivity
WHMCS menyediakan dua jenis log dengan tujuan berbeda:
// Log aktivitas: jejak audit singkat, terlihat di Activity Log
logActivity('Konfirmasi pembayaran diterima untuk invoice #' . $invoiceId, $clientId);
// Log modul: detail request/response untuk debugging integrasi
logModuleCall(
'vpsbridge', // nama modul
'CreateAccount', // aksi
$requestData, // data yang dikirim
$rawResponse, // respons mentah
$processedData, // hasil setelah diproses
[$apiKey, $password] // string yang harus disensor
);
logActivity | logModuleCall | |
|---|---|---|
| Lokasi | Activity Log | Configuration → System Logs → Module Log |
| Selalu aktif | Ya | Tidak, harus diaktifkan manual oleh admin |
| Isi | Pesan pendek untuk manusia | Payload request/response lengkap |
Dua jebakannya:
- Module log tidak aktif secara default. Dokumentasi menjelaskan ini disengaja agar log tidak membengkak. Jadi saat debugging, aktifkan dulu. Dan untuk error yang harus diketahui (misalnya suspend gagal), jangan hanya mengandalkan module log. Kembalikan pesan error ke admin atau buat to-do.
- Parameter
$replaceVarsadalah fitur keamanan. Setiap string di dalamnya diganti dengan tanda sensor di log. Masukkan password server, API key, dan password layanan ke sana. Tanpa itu, siapa pun yang punya akses ke System Logs bisa membaca kredensial dalam teks biasa.
Keamanan
Modul dan hook berjalan dengan hak penuh atas database billing. Kesalahan kecil di sini bisa berdampak langsung ke uang dan data pelanggan.
Cegah akses langsung ke file. Letakkan guard di awal setiap file modul:
if (!defined('WHMCS')) { die('This file cannot be accessed directly'); }Jangan percaya
$_GET/$_POST. Cast ID keint, validasi bahwa layanan/invoice yang diminta benar-benar milik client yang sedang login sebelum menampilkan atau mengubahnya. Endpoint kustom di client area adalah sumber IDOR (Insecure Direct Object Reference) paling umum.Escape output. Data dari database yang dirender ke HTML (nama client, judul tiket) harus di-escape. Di template Smarty gunakan modifier
escape, dan di PHP gunakanhtmlspecialchars().Form admin butuh proteksi CSRF. Saat membuat form POST di
_output, sertakan dan validasi token yang disediakan WHMCS. Jangan jalankan aksi yang mengubah data lewat GET.Upload file. Validasi MIME dan ekstensi, ganti nama file dengan nama acak, simpan di luar web root bila memungkinkan, dan jangan pernah memakai nama file asli dari pengguna di path.
Callback gateway wajib memverifikasi signature sebelum
addInvoicePayment(). Callback tanpa verifikasi berarti siapa pun bisa menandai invoice lunas dengan satu requestcurl.Rahasia di konfigurasi modul, bukan di kode. Simpan API key sebagai field
passworddi_configatau konfigurasi server, bukan hardcode di file PHP yang ikut ter-commit.
Praktik Aman Saat Upgrade
Upgrade WHMCS adalah momen di mana kustomisasi yang ceroboh terbongkar. Beberapa kebiasaan yang membuat upgrade membosankan (dalam arti baik):
- Jangan pernah mengubah file core atau template bawaan. Buat child theme atau template kustom, dan taruh logika di hook atau modul. File bawaan akan ditimpa saat upgrade.
- Beri prefix unik pada semua fungsi global (
acme_, nama modul). Fungsi global dari hook dan modul hidup di namespace yang sama dengan WHMCS dan modul pihak ketiga, dan tabrakan nama berarti fatal error di seluruh situs. - Gunakan hook point, bukan monkey-patch. Kalau perilaku yang kamu butuhkan tidak punya hook point, cari hook point terdekat (misalnya
ClientAreaPageuntuk variabel template) daripada mengedit file PHP inti. - Periksa changelog dan daftar deprecation. Hook point dan perintah API bisa berubah nama atau parameter. Simpan daftar hook point yang dipakai setiap modul agar mudah dicocokkan.
- Uji di staging dengan salinan data. Aktifkan module log, jalankan cron secara manual, dan coba siklus lengkap: order → create → suspend → unsuspend → terminate.
- Simpan kustomisasi di version control
. Satu repositori untuk
modules/addons/<milikmu>,modules/servers/<milikmu>, danincludes/hooks/*membuat deploy dan rollback bisa diulang, dan memisahkan dengan jelas mana kode milikmu dan mana milik WHMCS. - Hapus kode mati. File
.bakdan hook yang seluruh isinya dikomentari menumpuk seiring waktu dan menyesatkan pembaca. Kalau perlu disimpan, simpan di git history, bukan di folderincludes/hooks/.
Kesimpulan
Kunci mengembangkan WHMCS adalah menghormati batasnya: core tidak disentuh, dan semua kustomisasi masuk lewat hook, modul, dan API. Di dalam batas itu, WHMCS cukup fleksibel. Hook memberi reaksi terhadap event, addon memberi ruang untuk fitur mandiri, dan provisioning module menjembatani billing dengan infrastruktur nyata.
Kesalahan yang paling mahal hampir selalu sama: mengabaikan kontrak kembalian (hook yang exit(), modul yang selalu 'success'), menulis langsung ke tabel inti alih-alih lewat localAPI, dan tidak mencatat apa pun saat integrasi gagal. Perlakukan setiap modul seperti layanan kecil. Beri kontrak yang jelas, log yang aman, fallback manual yang terlacak, dan pertahanan terhadap input yang tidak bisa dipercaya.

Hey! I’m Fanny, the software engineer tending to this digital garden. You can read more about me, or subscribe by email.