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:

JenisLokasiKapan dipakai
Hookincludes/hooks/*.php atau modules/<tipe>/<nama>/hooks.phpMenyisipkan logika saat suatu event terjadi (client dibuat, invoice dibayar, halaman dirender)
Addon modulemodules/addons/<nama>/Fitur mandiri dengan halaman admin/client sendiri, tabel sendiri, dan konfigurasi
Provisioning (server) modulemodules/servers/<nama>/Menghubungkan produk ke sistem eksternal: create, suspend, terminate, upgrade
Registrar modulemodules/registrars/<nama>/Registrasi, transfer, perpanjangan domain, dan manajemen nameserver
Payment gatewaymodules/gateways/<nama>.php + callback/Menerima pembayaran dan menandai invoice lunas
APIlocalAPI() (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:

  1. WHMCS memuat semua file di includes/hooks/ pada setiap request. Tidak perlu registrasi, cukup taruh filenya.
  2. $vars berisi data kontekstual dari hook point. Untuk ShoppingCartValidateProductUpdate isinya adalah variabel REQUEST.
  3. 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 pointKembalian yang diharapkan
ClientAreaPageArray key/value berisi variabel template tambahan
ShoppingCartValidateCheckout, ShoppingCartValidateProductUpdateString (satu error) atau array string (banyak error)
TicketOpenValidationSatu string sebagai pesan validasi
AfterShoppingCartCheckoutTidak ada, murni notifikasi
ClientAreaPrimarySidebarTidak 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:

  1. 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.
  2. Akses properti sebelum cek null. $ticket->title dibaca sebelum $ticket dipastikan ada.
  3. redir() + exit() di hook validasi. Kontrak TicketOpenValidation adalah mengembalikan string error. Memotong eksekusi dengan exit() 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

FungsiDipicu ketika
_CreateAccountOrder dibayar atau admin menekan “Create”
_SuspendAccountLayanan jatuh tempo (overdue) atau suspend manual
_UnsuspendAccountInvoice overdue dibayar
_TerminateAccountLayanan sangat lama overdue atau terminate manual
_RenewInvoice perpanjangan dibayar
_ChangePackageUpgrade/downgrade produk atau opsi
_ChangePasswordPelanggan mengganti password layanan
_TestConnectionTombol “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 $replaceVars agar 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 APIExternal API
Cara panggillocalAPI($command, $values, $adminUser)HTTP POST ke endpoint API WHMCS
Dipakai dariHook dan modul (kode yang jalan di dalam WHMCS)Sistem lain: aplikasi, bot, integrasi
AutentikasiTidak perlu; berjalan di konteks WHMCSidentifier + secret (API credentials); admin terkait butuh permission API Access
OverheadPemanggilan fungsi PHPRound-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:

  1. Selalu cek $result['result']. localAPI tidak melempar exception saat perintah gagal. Ia mengembalikan array dengan result = error dan message. Kode lama saya memanggil localAPI(...) tanpa memeriksa hasilnya sama sekali, sehingga tiket yang gagal dibuat tidak pernah ketahuan.
  2. Argumen admin username. Sejak WHMCS 7.2 argumen ketiga bersifat opsional. Di kode lama sering terlihat angka 1 di 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(), dan insert_query() masih ada, tapi dokumentasi menyarankan untuk tidak memakainya.
  • Tabel kustom diberi prefix mod_. Jangan menambah kolom ke tabel tbl* 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 tblhosting untuk laporan itu wajar; mengubah tblinvoices.status secara langsung (seperti hook InvoiceCreated lama 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
);
logActivitylogModuleCall
LokasiActivity LogConfiguration → System Logs → Module Log
Selalu aktifYaTidak, harus diaktifkan manual oleh admin
IsiPesan pendek untuk manusiaPayload request/response lengkap

Dua jebakannya:

  1. 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.
  2. Parameter $replaceVars adalah 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 ke int, 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 gunakan htmlspecialchars().

  • 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 request curl.

  • Rahasia di konfigurasi modul, bukan di kode. Simpan API key sebagai field password di _config atau 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):

  1. 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.
  2. 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.
  3. Gunakan hook point, bukan monkey-patch. Kalau perilaku yang kamu butuhkan tidak punya hook point, cari hook point terdekat (misalnya ClientAreaPage untuk variabel template) daripada mengedit file PHP inti.
  4. 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.
  5. Uji di staging dengan salinan data. Aktifkan module log, jalankan cron secara manual, dan coba siklus lengkap: order → create → suspend → unsuspend → terminate.
  6. Simpan kustomisasi di version control . Satu repositori untuk modules/addons/<milikmu>, modules/servers/<milikmu>, dan includes/hooks/* membuat deploy dan rollback bisa diulang, dan memisahkan dengan jelas mana kode milikmu dan mana milik WHMCS.
  7. Hapus kode mati. File .bak dan hook yang seluruh isinya dikomentari menumpuk seiring waktu dan menyesatkan pembaca. Kalau perlu disimpan, simpan di git history, bukan di folder includes/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.

Comments