Shahid Malla
Development Featured

WHMCS Hooks Guide: Extend Functionality Without Modifying Core Files

Stop editing WHMCS core files. After 10+ years building WHMCS systems, here is the working model for hooks — the 7 you will actually use, a real walkthrough, and the pitfalls that have cost me nights of sleep.

S Shahid Malla
· Jan 4, 2026 · 14 min read · 161 views
shahidmalla.com/blog/whmcs-hooks-guide-extend-functionality-without-modifying-core-files
WHMCS Hooks Guide: Extend Functionality Without Modifying Core Files
On this page (18 sections)

If you've ever edited a WHMCS core file and then dreaded the next update — this guide is for you. I've spent 10+ years building WHMCS systems for hosting companies, and I can tell you with certainty: every single time someone "just patches the core to make it work," that patch silently breaks on the next update and takes someone's billing system down at 3 AM.

Hooks are the supported, future-proof way to bend WHMCS to your business. They survive updates. They keep your changes in one place. And once you understand the model, you can extend almost anything in WHMCS without touching a single core file.

This is the guide I wish I'd had when I started.

What hooks actually are (and aren't)

Forget the textbook definitions. Here's the working model I use:

A hook is a callback WHMCS executes at a specific moment in its lifecycle. When a client registers, when an invoice is generated, when an email is about to send — at each of these moments, WHMCS checks: "does anyone have code that wants to run here?" If you've registered a hook for that moment, your code runs.

Hooks split into two categories:

  • Action hooks — "something happened, react to it." You can't change the outcome; you can only do work on the side (send a Slack message, log to your CRM, provision an external resource).
  • Filter hooks — "I'm about to use this data — do you want to modify it?" You receive the data, return a modified version, and WHMCS uses your version.

The mental model that finally clicked for me: action hooks are observers, filter hooks are middleware.

The problem hooks actually solve

Last year I migrated a client off a WHMCS install that had been "customized" by a previous freelancer. The damage:

  • 17 modifications to /includes/invoicefunctions.php
  • 3 changed lines in /includes/clientfunctions.php
  • A patched /modules/gateways/stripe.php
  • Custom logic glued directly into the order form template

None of it was documented. None of it survived the WHMCS 8.x upgrade. Reproducing the behavior took me four full days of forensic git work.

If those same modifications had been written as hooks, they would have lived in /includes/hooks/, been version-controlled separately from WHMCS, and survived every update for years.

That's the value. Not "best practice for its own sake" — actual money saved at upgrade time.

Where hooks live

All custom hooks go in /includes/hooks/ relative to your WHMCS root. WHMCS auto-loads every .php file in this directory on every page request, so:

  • One file per concern. Don't dump all your hooks in custom.php. Split by responsibility: client_notifications.php, invoice_automation.php, order_provisioning.php.
  • Keep them lean. Every page load loads every hook file. Don't put 500 lines of logic inline — extract to a class and call it from the hook.
  • Never use require_once at the top of a hook file to pull WHMCS core. WHMCS is already bootstrapped when your hook runs. Reincluding causes function-redeclaration errors I've debugged more times than I'd like to admit.

Skeleton I start every new hook file with:

<?php
// /includes/hooks/client_notifications.php
//
// Reacts to client lifecycle events: registration, profile updates, cancellations.
// Sends to Slack #signups, CRM, and an internal welcome sequence.

if (!defined('WHMCS')) {
    die('This file cannot be accessed directly');
}

use WHMCS\Database\Capsule;

add_hook('ClientAdd', 1, function ($vars) {
    // ...
});

The defined('WHMCS') guard prevents the file from being executed by accident via a direct web request — a small detail, but I've seen exposed hook files leak sensitive logic.

Anatomy of add_hook()

add_hook('HookPointName', $priority, $callback);
  • HookPointName — the lifecycle event. The full list lives in the official hook index. Common ones: ClientAdd, InvoicePaid, AfterModuleCreate, EmailPreSend.
  • Priority — 1 to 99 (lower runs first). When multiple hooks target the same event, priority controls order. I default to 1 for hooks that must run early (security checks, validation) and 50 for normal business logic.
  • Callback — a closure or callable. It receives $vars, an associative array whose contents vary by hook point.

One thing the docs don't always make obvious: filter hooks must return the modified data. Action hooks return nothing. Returning the wrong thing from the wrong type silently breaks things.

The 7 hooks you'll actually use

WHMCS exposes hundreds of hook points. After building dozens of WHMCS systems, here are the seven I reach for again and again.

1. ClientAdd — new client onboarding

Fires after a new client account is created. Use this to push to your CRM, send a Slack notification, or trigger a welcome sequence.

add_hook('ClientAdd', 1, function ($vars) {
    $clientId  = $vars['userid'];
    $email     = $vars['email'];
    $firstName = $vars['firstname'];
    $lastName  = $vars['lastname'];

    // Push to internal CRM (real example: HubSpot API)
    $payload = json_encode([
        'properties' => [
            ['property' => 'email',     'value' => $email],
            ['property' => 'firstname', 'value' => $firstName],
            ['property' => 'lastname',  'value' => $lastName],
            ['property' => 'lifecyclestage', 'value' => 'customer'],
        ],
    ]);

    $ch = curl_init('https://api.hubapi.com/contacts/v1/contact/');
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => $payload,
        CURLOPT_HTTPHEADER     => [
            'Content-Type: application/json',
            'Authorization: Bearer ' . getenv('HUBSPOT_TOKEN'),
        ],
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 5,
    ]);
    curl_exec($ch);
    curl_close($ch);

    logActivity("Client #$clientId pushed to HubSpot");
});

Notice the CURLOPT_TIMEOUT => 5. This is critical. Without a timeout, a stalled CRM API hangs the registration flow and your customer sees a white page. I've seen this take down signup forms for hours. Always timeout external calls from hooks. Better still: push to a queue and process asynchronously.

2. InvoicePaid — post-payment automation

Fires when an invoice transitions to Paid. Best place to trigger anything that depends on confirmed payment: license generation, external provisioning, accounting sync.

add_hook('InvoicePaid', 1, function ($vars) {
    $invoiceId = $vars['invoiceid'];

    $invoice = Capsule::table('tblinvoices')->where('id', $invoiceId)->first();
    if (!$invoice) return;

    // Was this invoice for a service tagged as "lifetime"?
    $items = Capsule::table('tblinvoiceitems')
        ->where('invoiceid', $invoiceId)
        ->where('type', 'Hosting')
        ->get();

    foreach ($items as $item) {
        $service = Capsule::table('tblhosting')->where('id', $item->relid)->first();
        if (!$service) continue;

        $productCustomFields = Capsule::table('tblcustomfields')
            ->where('relid', $service->packageid)
            ->where('type', 'product')
            ->where('fieldname', 'License Type')
            ->first();

        if ($productCustomFields && $productCustomFields->fieldname === 'Lifetime') {
            // Generate and email a lifetime license
            generateLifetimeLicense($service->id, $service->userid);
        }
    }
});

Two patterns to copy from this snippet: use Capsule (WHMCS ships Laravel's query builder) instead of raw mysql_* calls, and always check for null after a first() — invoice records can disappear between events on busy systems.

3. EmailPreSend — filter outgoing emails

This is a filter hook. Modify any outgoing email before WHMCS sends it. The use case I most commonly build: append signature blocks, inject account-manager contact info for VIP clients, or block sends to test addresses on staging.

add_hook('EmailPreSend', 1, function ($vars) {
    // Block all outgoing email on staging unless going to the dev team
    if (strpos($_SERVER['HTTP_HOST'] ?? '', 'staging.') === 0) {
        $devs = ['[email protected]', '[email protected]'];
        if (!in_array(strtolower($vars['to'] ?? ''), $devs, true)) {
            return ['abortsend' => true]; // tells WHMCS to skip this send
        }
    }

    // Inject account manager signature for VIP clients
    if (!empty($vars['relid'])) {
        $am = getAccountManagerForClient($vars['relid']);
        if ($am) {
            $vars['html'] = str_replace(
                '{signature_placeholder}',
                "<p>Your account manager: {$am->name} ({$am->email})</p>",
                $vars['html']
            );
        }
    }

    return $vars; // filter hooks MUST return the (possibly modified) data
});

The abortsend trick is a lifesaver. I add it on every staging system I touch — you do not want to discover at 9 AM that staging blasted real renewal notices to live customers.

4. AfterModuleCreate — post-provisioning

Fires after a service module's CreateAccount function runs successfully. The right place to do anything that needs the external account to already exist — set up monitoring, populate DNS, send custom welcome email with login details.

add_hook('AfterModuleCreate', 1, function ($vars) {
    $serviceId = $vars['params']['serviceid'];
    $domain    = $vars['params']['domain'];
    $server    = $vars['params']['serverhostname'];

    // Add to Uptime Robot monitoring
    addUptimeMonitor("https://$domain", "Customer site: $domain");

    // Configure Cloudflare DNS automatically
    configureCloudflareDns($domain, $server);

    logActivity("Service #$serviceId monitoring + DNS configured");
});

5. DailyCronJob — scheduled tasks

Runs once per day when the WHMCS cron fires. Use it for daily housekeeping: stale-data cleanup, daily reports, license-expiry sweeps.

add_hook('DailyCronJob', 1, function () {
    // Find services renewing in 7 days that don't have an unpaid invoice yet
    $upcoming = Capsule::table('tblhosting')
        ->where('domainstatus', 'Active')
        ->whereDate('nextduedate', now()->addDays(7)->toDateString())
        ->get();

    foreach ($upcoming as $service) {
        sendCustomReminderEmail($service->id);
    }

    logActivity("Sent renewal reminders for " . $upcoming->count() . " services");
});

Never put long-running work directly in DailyCronJob. The cron job has a wall-clock limit; if your hook hangs, the rest of the cron skips. For heavy work, queue the IDs and process them in batches via a separate worker.

6. ClientAreaPage — inject variables into client area

Filter hook that runs on every client-area page load. Use it to expose extra data to the templates without editing them.

add_hook('ClientAreaPage', 1, function ($vars) {
    if (!isset($_SESSION['uid'])) return $vars;

    $clientId = $_SESSION['uid'];

    // Calculate loyalty status and pass it to the template
    $monthsActive = Capsule::table('tblclients')
        ->where('id', $clientId)
        ->value(Capsule::raw('TIMESTAMPDIFF(MONTH, datecreated, NOW())'));

    $vars['loyaltyStatus'] = match (true) {
        $monthsActive >= 60 => 'Diamond',
        $monthsActive >= 36 => 'Platinum',
        $monthsActive >= 12 => 'Gold',
        default             => 'Silver',
    };

    return $vars;
});

Then in your client-area template: {$loyaltyStatus}.

7. ShoppingCartValidateCheckout — block bad orders

This is the hook I use to stop fraud before it costs money. Block based on email patterns, country, suspicious cart contents, anything you can detect.

add_hook('ShoppingCartValidateCheckout', 1, function ($vars) {
    $errors = [];

    // Block disposable email domains
    $disposable = ['mailinator.com', 'tempmail.com', '10minutemail.com', 'guerrillamail.com'];
    $emailDomain = strtolower(substr(strrchr($_SESSION['cart']['user']['email'] ?? '', '@'), 1));
    if (in_array($emailDomain, $disposable, true)) {
        $errors[] = 'Please use a permanent email address.';
    }

    // Block matched fraud signals (your own lookup table)
    if (isKnownFraudIP($_SERVER['REMOTE_ADDR'] ?? '')) {
        $errors[] = 'Order cannot be processed. Contact support if you believe this is an error.';
    }

    return $errors; // empty array = allow checkout; non-empty = show errors to user
});

Returning an array of strings stops checkout and shows the strings to the user. Returning an empty array (or returning nothing) allows it to proceed.

Building your first hook from zero

Let's walk a real one end-to-end. We'll build a hook that posts to Slack every time a new client signs up.

Step 1 — Create the file.

touch /path/to/whmcs/includes/hooks/slack_signups.php
chmod 644 /path/to/whmcs/includes/hooks/slack_signups.php

File permissions matter. 644 means readable by the web server but not writable. If your hook ever gets compromised, you don't want it self-modifying.

Step 2 — Get a Slack incoming webhook URL.

In Slack: Apps → Manage → Incoming Webhooks → Add to Workspace → pick the channel → copy the URL. Keep this URL secret — anyone with it can post to your channel.

Step 3 — Write the hook.

<?php
// /includes/hooks/slack_signups.php

if (!defined('WHMCS')) {
    die('This file cannot be accessed directly');
}

add_hook('ClientAdd', 50, function ($vars) {
    $webhookUrl = getenv('SLACK_SIGNUPS_WEBHOOK');
    if (!$webhookUrl) return; // fail silently if not configured

    $message = [
        'text' => sprintf(
            "New signup: %s %s (%s) — Client #%d",
            $vars['firstname'],
            $vars['lastname'],
            $vars['email'],
            $vars['userid']
        ),
    ];

    $ch = curl_init($webhookUrl);
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => json_encode($message),
        CURLOPT_HTTPHEADER     => ['Content-Type: application/json'],
        CURLOPT_TIMEOUT        => 3,
        CURLOPT_RETURNTRANSFER => true,
    ]);
    curl_exec($ch);
    curl_close($ch);
});

Step 4 — Set the webhook URL.

Add to your server environment (in /etc/environment, your systemd unit, or your hosting panel's env vars):

SLACK_SIGNUPS_WEBHOOK=https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXX

Never hardcode the URL in the PHP file. If your /includes/hooks/ gets accidentally exposed (it happens), you'll have leaked a webhook anyone can spam.

Step 5 — Test it.

Create a test client in your WHMCS admin (Clients → Add New Client). Within a few seconds, the Slack message should appear. If it doesn't, jump to the troubleshooting section below.

How to verify your hook actually ran

This is the question I get asked most often. WHMCS doesn't tell you "your hook fired" — you have to instrument it. Three reliable techniques:

  1. Activity log. Call logActivity("descriptive message") from inside the hook. Then check Utilities → Logs → Activity Log in WHMCS admin.
  2. WHMCS debug log. Enable it in Setup → General Settings → Other → Display Errors. Then write to it via logModuleCall('myhook', 'action', $input, $output). This captures input/output for forensics.
  3. File log fallback. When even WHMCS's logging fails (early-bootstrap hooks), write to a file you control: file_put_contents('/tmp/myhook.log', date('c') . " fired\n", FILE_APPEND);. Remember to remove this before going to production.

Common pitfalls (and how to debug them)

"My hook isn't firing at all."

  • Confirm the file is in /includes/hooks/, not a subdirectory.
  • Check the file is syntactically valid: php -l /path/to/hook.php. A single syntax error makes WHMCS skip the whole file.
  • Confirm you spelled the hook point exactly — they're case-sensitive. ClientAdd works, clientAdd does not.
  • Some hooks only fire from specific entry points. ClientAdd fires from admin + signup + API, but AddInvoicePayment only fires when adding a payment, not when manually marking paid.

"My filter hook returns data but WHMCS uses the original."

You forgot to return $vars;. Filter hooks must return the (possibly modified) data. Action hooks return nothing. Mixing them up is the most common bug I see.

"My hook fires twice."

You probably registered the same hook in two files, or you reloaded the hook file with require_once. Don't do that. WHMCS auto-loads everything in /includes/hooks/ exactly once.

"My hook is making page loads slow."

Network calls in synchronous hooks are the culprit 90% of the time. The fix:

  1. Add timeouts to every external call (3-5 seconds max).
  2. For non-critical work, push to a queue and let a separate worker process it.
  3. Hooks like ClientAreaPage fire on every page load. Anything in them runs on every page load. Keep them trivial.

Performance considerations

Hooks have a cost. On busy WHMCS installs, I've seen poorly written hooks add 200-400ms to every page load. Rules I follow:

  • Cache aggressively. If your hook fetches data that doesn't change every second, cache it. WHMCS doesn't ship a cache layer, but you can drop in Redis with three lines of code.
  • Avoid ClientAreaPage for anything heavy. Pre-compute and store, don't compute on every load.
  • Profile before optimizing. Enable WHMCS slow-query logging and look at what's actually slow. Often the hook isn't the problem — a join inside it is.

My take — when not to use hooks

Hooks are the right answer most of the time. But they're not the only answer.

  • If you're modifying template output, edit the template (in your custom theme directory). Don't rebuild HTML with a filter hook just because you can.
  • If you're integrating an external system end-to-end, write a proper module — a provisioning, addon, or gateway module. Modules have an upgrade path and configuration UI. Hooks are best for "react to event."
  • If the logic touches many tables or has business rules that will evolve, build a small Laravel/PHP app alongside WHMCS and call it from a hook. Don't grow hook files into a monolith.

The rule I use: if it fits in one file and reacts to one event, it's a hook. If it has its own settings page or runs against many events, it's a module.

Going further

  • Official WHMCS hook index — the canonical reference. Bookmark this.
  • WHMCS hook documentation — the official getting-started guide.
  • Look at /modules/addons/ in your WHMCS install for examples of well-structured custom code. The WHMCS-shipped addons aren't always exemplary, but they show the conventions.

I build WHMCS hooks, modules, and full automation systems for hosting businesses. If you're stuck on a specific hook or have a workflow that hooks would solve — tell me what you need and I'll send a quote within 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.