Shahid Malla

Building Custom WHMCS Reports: Admin Dashboard Analytics

WHMCS built-in reports cover basics. For MRR by product, cohort retention, churn, SLA tracking — you write custom reports. The easiest WHMCS module type to ship.

S Shahid Malla
· Feb 22, 2026 · 5 min read · 83 views
shahidmalla.com/blog/building-custom-whmcs-reports-admin-dashboard-analytics
Building Custom WHMCS Reports: Admin Dashboard Analytics
On this page (15 sections)

WHMCS's built-in reports are fine for "how much revenue last month." For anything else — cohort retention, MRR by acquisition channel, churn by product, support response times by department — you need custom reports. The good news: reports are the easiest WHMCS module type to write. A single PHP file, no UI, no install ceremony.

How custom reports work

WHMCS auto-discovers any PHP file in /modules/reports/. The file sets variables; WHMCS renders them as a report page with title, description, table, optional chart.

No activation step. No configuration UI. Drop the file in, refresh Reports in the admin sidebar, your report appears.

Step 1 — A minimum viable report

Create /modules/reports/active_services_by_country.php:

<?php

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

use WHMCS\Database\Capsule;

$reportdata['title'] = 'Active Services by Country';
$reportdata['description'] = 'Count of active hosting services grouped 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 it, refresh Reports → Active Services by Country appears in the list.

Step 2 — Reports that matter

Reports I write on almost every WHMCS deployment:

1. MRR by Product

$rows = Capsule::table('tblhosting')
    ->join('tblproducts', 'tblhosting.packageid', '=', 'tblproducts.id')
    ->where('tblhosting.domainstatus', 'Active')
    ->where('tblhosting.billingcycle', 'Monthly')
    ->groupBy('tblproducts.name')
    ->selectRaw("tblproducts.name, COUNT(*) as customers, SUM(tblhosting.amount) as mrr")
    ->get();

2. Churn Rate (last 30 days)

$cancelled = Capsule::table('tblhosting')
    ->where('domainstatus', 'Cancelled')
    ->where('updated_at', '>=', now()->subDays(30))
    ->count();

$active = Capsule::table('tblhosting')
    ->where('domainstatus', 'Active')
    ->count();

$churnRate = ($cancelled / max($active + $cancelled, 1)) * 100;

3. Support response time by department

$rows = Capsule::table('tbltickets')
    ->join('tblticketreplies', 'tbltickets.id', '=', 'tblticketreplies.tid')
    ->join('tblticketdepartments', 'tbltickets.did', '=', 'tblticketdepartments.id')
    ->groupBy('tblticketdepartments.name')
    ->selectRaw("tblticketdepartments.name as department,
                AVG(TIMESTAMPDIFF(MINUTE, tbltickets.date, tblticketreplies.date)) as avg_response_min")
    ->get();

4. Top customers by lifetime value

$rows = Capsule::table('tblinvoices')
    ->join('tblclients', 'tblinvoices.userid', '=', 'tblclients.id')
    ->where('tblinvoices.status', 'Paid')
    ->groupBy('tblclients.id')
    ->selectRaw("tblclients.id, CONCAT(tblclients.firstname, ' ', tblclients.lastname) as name,
                tblclients.email, COUNT(*) as paid_invoices, SUM(tblinvoices.total) as lifetime_value")
    ->orderByDesc('lifetime_value')
    ->limit(50)
    ->get();

5. Failed payment summary (recent)

$rows = Capsule::table('tblinvoices')
    ->join('tblclients', 'tblinvoices.userid', '=', 'tblclients.id')
    ->where('tblinvoices.status', 'Unpaid')
    ->where('tblinvoices.duedate', '<', now())
    ->selectRaw("tblinvoices.id, tblinvoices.total, tblinvoices.duedate, tblclients.email")
    ->orderBy('duedate')
    ->get();

Step 3 — Add a chart

WHMCS supports basic chart rendering. Set $reportdata['drawchart']:

$reportdata['drawchart']   = true;
$reportdata['charttype']   = 'Bar';   // or 'Pie', 'Line'
$reportdata['chartheight'] = '300px';
$reportdata['chartcols']   = [
    'Country',          // label column
    'Active Services',  // numeric column
];

The chart uses the same tableheadings + tablevalues data; you just tell WHMCS which columns to render.

Step 4 — Add date range filters

Most reports benefit from "show me last 7 days" / "last 30 days" / "this year." Add a form at the top:

$startdate = $_REQUEST['startdate'] ?? date('Y-m-d', strtotime('-30 days'));
$enddate   = $_REQUEST['enddate']   ?? date('Y-m-d');

$reportdata['title'] = 'Revenue by Day';
$reportdata['description'] = sprintf('Daily revenue from %s to %s', $startdate, $enddate);
$reportdata['headertext'] = '
    <form method="POST">
        <label>Start: <input type="date" name="startdate" value="' . htmlspecialchars($startdate) . '"></label>
        <label>End: <input type="date" name="enddate" value="' . htmlspecialchars($enddate) . '"></label>
        <button type="submit">Update</button>
    </form>';

$rows = Capsule::table('tblinvoices')
    ->where('status', 'Paid')
    ->whereBetween('datepaid', [$startdate, $enddate])
    ->selectRaw("DATE(datepaid) as day, SUM(total) as revenue")
    ->groupBy('day')
    ->get();

Step 5 — CSV export

WHMCS reports include a built-in CSV export button. No code needed — appears automatically when the report has tablevalues.

For more complex exports, link to a separate endpoint that formats Excel / JSON / whatever.

How to verify your report

  1. Drop the file in /modules/reports/.
  2. Refresh WHMCS Admin → Reports.
  3. Your report appears. Click it.
  4. Data renders. Filters work. CSV export works.

Common pitfalls

"Report doesn't appear in the list." File has a PHP syntax error. php -l /path/to/your-report.php to confirm.

"Query crashes WHMCS." Heavy join across large tables. Add proper indexes; consider running the report against a read replica.

"Numbers don't match WHMCS's own dashboard." WHMCS counts certain things differently (e.g., revenue vs. paid revenue vs. accrued). Cross-check your query against WHMCS's own reports — it's usually a definitional difference, not a bug.

"Report takes >30s to load." Break the query into a nightly aggregate job (a custom table you populate via cron) and have the report just read the pre-computed aggregates.

My take — reports drive better decisions

The hosting business that knows its MRR by product, its churn by tenure cohort, and its support response time by team — operates differently from one that just looks at "total revenue this month." Spend a couple of hours per quarter adding the reports you need. Each report you build is leverage for every business decision after.

Going further


I build custom WHMCS reports for hosting businesses — MRR, churn, cohort retention, ticket SLAs, anything you can query. Tell me what you want to measure 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.