Shahid Malla

WHMCS Custom Module Development: Picking the Right Type

The five WHMCS module types — provisioning, addon, gateway, registrar, reports — and a decision tree for picking the right one. With boilerplate for each.

S Shahid Malla
· Jan 20, 2026 · 8 min read · 134 views
shahidmalla.com/blog/whmcs-custom-module-development-picking-the-right-type
WHMCS Custom Module Development: Picking the Right Type
On this page (15 sections)

"Build a custom WHMCS module" is one of those phrases that hides four completely different jobs. WHMCS has five module types — provisioning, addon, gateway, registrar, and reports — and choosing the wrong one means rebuilding from scratch later. This is the decision tree I run through before writing a single line of module code.

I've shipped 200+ WHMCS modules over the last decade. Some on the Marketplace, most as one-off builds for hosting clients. Here's the map of the territory.

The five module types — what each one is for

TypeLives inUse when
Provisioning (Server) /modules/servers/ WHMCS sells access to something — a hosting account, a license, a VPS, an IPTV line. You need create/suspend/terminate lifecycle.
Addon /modules/addons/ You're adding a feature to WHMCS itself — a new admin page, a new client-area section, a background job. Not selling access; extending the platform.
Payment Gateway /modules/gateways/ You're integrating a way for clients to pay (Stripe, PayPal, Razorpay, crypto, a regional bank).
Registrar /modules/registrars/ You're integrating a domain registrar's API for domain registration, transfer, renewal.
Reports /modules/reports/ You want a custom admin report (CSV, table, chart) that pulls from any data source.

The mistake I see most often: people build "an addon module that also creates accounts on a remote panel." That's a provisioning module dressed up as an addon. The result is fragile because they're hand-rolling the lifecycle WHMCS would give them for free if they used the right type.

A decision tree

  1. Are you selling something on a recurring basis where each customer needs an account or instance? → Provisioning module.
  2. Are you handling money? → Payment gateway.
  3. Are you registering or managing domains? → Registrar.
  4. Are you running a database query and showing a table? → Reports module.
  5. Otherwise → Addon module.

Boilerplate — minimum viable for each type

I keep these as starter templates so I never have to remember the conventions. The folder name in every case becomes the module name throughout WHMCS — pick well.

Provisioning module

Covered in depth in my provisioning module guide. Boilerplate signature:

/modules/servers/yourmodule/yourmodule.php

function yourmodule_MetaData()         { /* ... */ }
function yourmodule_ConfigOptions()    { /* ... */ }
function yourmodule_CreateAccount()    { /* ... */ }
function yourmodule_SuspendAccount()   { /* ... */ }
function yourmodule_UnsuspendAccount() { /* ... */ }
function yourmodule_TerminateAccount() { /* ... */ }
function yourmodule_ChangePassword()   { /* ... */ }
function yourmodule_ChangePackage()    { /* ... */ }

Addon module

/modules/addons/yourmodule/yourmodule.php

function yourmodule_config()
{
    return [
        'name'        => 'Your Module',
        'description' => 'What it does',
        'version'     => '1.0.0',
        'author'      => 'Your Brand',
        'fields'      => [
            'api_key' => [
                'FriendlyName' => 'API Key',
                'Type'         => 'password',
                'Description'  => 'External service API key',
            ],
        ],
    ];
}

function yourmodule_activate()   { /* called when admin activates module */ }
function yourmodule_deactivate() { /* called when admin deactivates */ }
function yourmodule_upgrade($vars) { /* called between version upgrades */ }

function yourmodule_output($vars)
{
    // This renders the admin page at /admin/addonmodules.php?module=yourmodule
    echo '<h2>Your Module Admin</h2>';
    echo '<p>Hello, admin!</p>';
}

function yourmodule_sidebar($vars)
{
    // Optional sidebar in admin
    return 'Sidebar content here';
}

function yourmodule_clientarea($vars)
{
    // Optional client-area page at /index.php?m=yourmodule
    return [
        'pagetitle'    => 'Your Module',
        'breadcrumb'   => ['index.php?m=yourmodule' => 'Your Module'],
        'templatefile' => 'overview',
        'requirelogin' => true,
        'vars'         => [
            'message' => 'Hello, client',
        ],
    ];
}

Payment gateway

/modules/gateways/yourgateway/yourgateway.php

function yourgateway_MetaData()
{
    return [
        'DisplayName' => 'Your Gateway',
        'APIVersion'  => '1.1',
    ];
}

function yourgateway_config()
{
    return [
        'FriendlyName' => ['Type' => 'System', 'Value' => 'Your Gateway'],
        'apiKey'       => ['FriendlyName' => 'API Key',    'Type' => 'password'],
        'webhookSecret'=> ['FriendlyName' => 'Webhook Secret', 'Type' => 'password'],
    ];
}

function yourgateway_link($params)
{
    // Returns the HTML that renders the "Pay" button or hosted-checkout form
    $invoiceId = $params['invoiceid'];
    $amount    = $params['amount'];
    // ...
    return '<form action="https://gateway.example.com/checkout" method="POST">...</form>';
}

// callback.php in the same folder receives webhook posts from the gateway

Registrar

/modules/registrars/yourregistrar/yourregistrar.php

function yourregistrar_getConfigArray()         { /* fields */ }
function yourregistrar_RegisterDomain($params)  { /* ... */ }
function yourregistrar_TransferDomain($params)  { /* ... */ }
function yourregistrar_RenewDomain($params)     { /* ... */ }
function yourregistrar_GetNameservers($params)  { /* ... */ }
function yourregistrar_SaveNameservers($params) { /* ... */ }
function yourregistrar_GetContactDetails($params)  { /* ... */ }
function yourregistrar_SaveContactDetails($params) { /* ... */ }
function yourregistrar_GetEPPCode($params)      { /* ... */ }

Reports

/modules/reports/yourreport.php

$reportdata['title']       = 'Active Services by Country';
$reportdata['description'] = 'Distribution of active hosting services by client country.';

$reportdata['tableheadings'] = ['Country', 'Active Services', 'Revenue (USD)'];

$rows = Capsule::table('tblhosting')
    ->join('tblclients', 'tblhosting.userid', '=', 'tblclients.id')
    ->where('tblhosting.domainstatus', 'Active')
    ->groupBy('tblclients.country')
    ->selectRaw("tblclients.country, COUNT(*) as services, SUM(tblhosting.amount) as revenue")
    ->orderByDesc('services')
    ->get();

foreach ($rows as $r) {
    $reportdata['tablevalues'][] = [
        $r->country ?: 'Unknown',
        $r->services,
        number_format($r->revenue, 2),
    ];
}

Drop that one file in /modules/reports/ and WHMCS auto-discovers it. Reports modules are the easiest to write — they're often where I start with WHMCS-newcomer engineers.

Setting up a dev environment that doesn't break production

The number-one rule I drill into anyone touching WHMCS code: never develop against production. Three setups that have worked well for me:

  1. Local PHP + MySQL. WHMCS runs fine on PHP 8.1/8.2 with MySQL 5.7+. Use a free 30-day WHMCS dev license (request from WHMCS).
  2. Staging copy on a VPS. Clone production DB + files to a separate domain, change configuration.php license to dev license, run on its own subdomain.
  3. Docker image. I keep a docker-compose stack with WHMCS + MySQL + a mock external API service for testing.

On any of these, enable error display in Setup → General Settings → Other → Display Errors. On production, it must be off. On dev, it must be on. The number of "the module silently does nothing" bug reports that turn out to be a syntax error you'd have seen instantly with error display on — too many.

Distribution strategies — selling vs. one-off

Once your module works, you have three ways to ship it:

  1. One-off install. You hand the client a zip, they upload it. Simplest. Works for client projects.
  2. WHMCS Marketplace. Submit your module to the official Marketplace. WHMCS takes a cut, you get distribution. Approval takes weeks; updates take weeks too. Best for stable, broadly-applicable modules.
  3. Self-hosted licensing. Sell it from your own site, license it with the official WHMCS Licensing Addon. You control pricing, updates, support. This is what I do for WHMCSPilot modules.

The licensing addon model gives you the most control but requires building license-validation into every module. If you want to skip that complexity early, start with one-off installs and graduate later.

Versioning and upgrades

For addon modules, WHMCS supports versioned upgrades. Implement yourmodule_upgrade($vars) with version checks:

function yourmodule_upgrade($vars)
{
    $oldVersion = $vars['version'];

    if (version_compare($oldVersion, '1.1.0', '<')) {
        Capsule::schema()->table('mod_yourmodule_data', function ($table) {
            $table->string('new_field')->nullable();
        });
    }

    if (version_compare($oldVersion, '2.0.0', '<')) {
        // bigger migration
    }
}

WHMCS calls this when the version in config() is higher than the installed version. Use it to migrate your own module's schema cleanly.

How to verify a module works end-to-end

  1. WHMCS shows the module in the right place. Provisioning modules appear under Setup → Products → Product → Module Settings. Addons under Setup → Addon Modules. Etc.
  2. Activation succeeds. For addons, "Activate" must not error. Watch for "Module not detected" — usually a folder/file naming mismatch.
  3. Each lifecycle function runs. Use the admin buttons (Create, Suspend, Terminate for provisioning) and watch Utilities → Logs → Module Log.
  4. Errors are clean. Force a failure (wrong API key, missing field) and confirm your error string surfaces in the WHMCS UI, not a stack trace.

Common pitfalls (with fixes)

"Module not detected." The PHP file's name must exactly match the folder. /modules/addons/yourmodule/yourmodule.php, not your_module.php or main.php.

"Function returns success but WHMCS marks failure." You returned true, 1, or an array. Return the literal string 'success'.

"Config fields aren't saving." Config field types must match WHMCS-accepted types (text, password, yesno, dropdown, radio, textarea). Custom types are silently ignored.

"My module page is missing the navbar / footer." For addon client-area pages, set 'requirelogin' => true and return correct 'templatefile'. The WHMCS layout wraps your template only when these are right.

"Module works for me, breaks for the customer." 90% of the time: you tested as admin (which bypasses some checks); the customer hits permissions code paths that don't exist for admins. Test by impersonating a real client account.

My take — what I wish someone had told me

  • Keep modules small. One module = one responsibility. If your module is more than 1,500 lines of code, it's two modules.
  • Don't reinvent core WHMCS UI. Use WHMCS's built-in admin form helpers, table renderers, button styles. Your module looks like part of the platform, not an alien graft.
  • Document the config fields. Use the Description property on every config field. Future-you (and your customer) will thank you.
  • Write a real README. Include install steps, config field meanings, supported PHP/WHMCS versions, change log. This pays for itself.

Going further


I build all five WHMCS module types — including marketplace-quality ones with their own admin UIs, licensing, and update channels. If you have a workflow that doesn't fit out of the box, tell me what you need and I'll send a quote in 24 hours.

Share this article

S

Written by

Shahid Malla

WHMCS expert, full-stack developer, technical lead at Fada.cloud. 10+ years building hosting platforms, custom modules, and automation that ships.

Trusted platforms

Prefer to hire through a platform?

Not sure about working directly? Hire me through Fiverr or Upwork instead - same me, same work, with the platform's buyer protection and escrow.

Got a project like this?

Tell me what you need - I'll send a real quote within 24 hours.