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
- Drop the file in
/modules/reports/. - Refresh WHMCS Admin → Reports.
- Your report appears. Click it.
- 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
- WHMCS reports developer docs
- Laravel query builder docs — Capsule is the same API.
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.