darthsoup/php-whmcs-api
A lightweight PHP client for the WHMCS API, offering fluent methods to query clients, orders, invoices, tickets, and more. Simplifies authentication and API responses so you can integrate WHMCS billing and support data into PHP or Laravel apps with minimal setup.
This package is a thin, GitLabPHP/Client-inspired HTTP client for WHMCS. It collapses WHMCS's single POST /includes/api.php endpoint into resource objects (clients(), orders(), invoices(), etc.), handles the JSON envelope, and lets you inject any PSR-7-compatible HTTP client.
Minimum setup
composer require darthsoup/php-whmcs-api
# Only if you don't already have a PSR-7/PSR-18 or HTTPlug implementation:
composer require guzzlehttp/guzzle php-http/guzzle7-adapter
Check the package's composer.json to see whether it uses HTTPlug discovery or a PSR-18 client, and install the matching adapter.
use DarthSoup\WhmcsApi\Client;
$whmcs = new Client();
$whmcs->setUrl('https://billing.example.com/'); // WHMCS root, not /includes/api.php
$whmcs->setCredentials($apiIdentifier, $apiSecret);
$clients = $whmcs->clients()->all(['limitnum' => 25]);
The first useful end-to-end slice is: authenticate, fetch one client by ID, then fetch that client's orders/invoices. If you can do that, the rest of the resource API is mostly parameter mapping.
Where to look first
src/Client.php — URL/credential setters, HTTP client injection (createWithHttpClient or equivalent), and the magic __call/api() resource resolution.src/Api/AbstractApi.php — how the action name, identifier/secret, responsetype=json, and parameters are assembled into the POST body.src/Api/*.php — the supported resources and exact method names. WHMCS has no clean CRUD model, so expect a mix of GitLab-style methods (all(), show(), create()) and WHMCS action names (getClientsDetails, addOrder, addInvoicePayment).If the exact setter or method name differs in your installed version, the files above are the source of truth; the integration pattern is the same.
Bind the client as a singleton
Do not construct a client per call. In Laravel:
use DarthSoup\WhmcsApi\Client;
$this->app->singleton(Client::class, function () {
$client = new Client();
$client->setUrl(config('services.whmcs.url'));
$client->setCredentials(
config('services.whmcs.identifier'),
config('services.whmcs.secret')
);
return $client;
});
Use one client per WHMCS installation. If you run multiple brands or regions, key clients by installation and resolve them from a small factory.
Wrap it in a repository/adapter
The package returns WHMCS-shaped arrays, not domain objects. Keep those arrays out of your application code:
final class WhmcsCustomerRepository
{
public function __construct(private Client $whmcs) {}
public function find(int $clientId): ?Customer
{
$response = $this->whmcs->clients()->show(['clientid' => $clientId]);
if (($response['result'] ?? null) !== 'success') {
return null;
}
return Customer::fromWhmcs($response);
}
}
This also gives you one place to change when the package is upgraded or replaced.
Paginate explicitly
WHMCS list actions use limitstart and limitnum, and the default page size is often smaller than you expect:
$limit = 100;
$start = 0;
$all = [];
do {
$page = $whmcs->clients()->all([
'limitstart' => $start,
'limitnum' => $limit,
]);
$batch = $page['clients'] ?? [];
$all = array_merge($all, $batch);
$start += $limit;
} while (count($batch) === $limit);
Stop on a short page or when startnumber + numreturned >= totalresults, whichever the response exposes. Avoid huge limitnum values; WHMCS may time out generating large result sets.
Treat writes as jobs, not request-path calls
AddOrder, AddClient, AddInvoicePayment, OpenTicket, and AddTicketReply are mutating, non-idempotent actions. Put them behind queued jobs with:
result: error.AddOrder may still have created the order.Use noinvoice / noemail parameters for staging tests so you do not email real customers or create real invoices.
Read/cache aggressively
Client details, product lists, and currency configuration change infrequently. Cache them for a few minutes to avoid making every page render depend on WHMCS latency. Invalidate on your own writes.
Use WHMCS hooks for events, not API polling
The WHMCS API is pull-only; there are no webhooks. For order/ticket/invoice events, add a WHMCS hook that calls your application's webhook endpoint, then use this client for lookups and follow-up actions. Polling GetTickets or GetOrders on a short interval is expensive and easy to get rate-limited by WHMCS/PHP execution limits.
Custom or missing actions
If an action is not wrapped, look at AbstractApi and either extend an existing API class or add a small custom class that calls the same generic request path. Keep custom code in your application rather than forking the package; the package is small enough that a subclass is usually sufficient.
Testing
Inject a mock HTTP client and return PSR-7 responses with JSON fixtures:
use Http\Mock\Client as MockClient;
use Nyholm\Psr7\Response;
$mock = new MockClient();
$mock->addResponse(new Response(200, [], json_encode([
'result' => 'success',
'totalresults' => 1,
'clients' => [['id' => 1, 'firstname' => 'Ada']],
])));
$whmcs = Client::createWithHttpClient($mock);
$whmcs->setUrl('https://billing.test/');
$whmcs->setCredentials('id', 'secret');
If your installed version uses PSR-18 instead of HTTPlug, use Guzzle's MockHandler in the same way. Test your repository/adapter against realistic WHMCS payloads, including error envelopes and empty result sets.
WHMCS returns HTTP 200 for API errors. A failed call is still 200 OK with {"result":"error","message":"..."}. Always inspect result, and log message with the action and sanitized parameters. Do not rely on HTTP status codes or exceptions alone.
responsetype=json must be set. Without it, WHMCS returns XML and PSR-7 JSON parsing fails. The package should add it in AbstractApi; verify this first if you get XML parse errors.
Point the URL at the WHMCS root. Use https://billing.example.com/ or https://example.com/whmcs/, not https://.../includes/api.php. Watch trailing slashes; a double slash can produce confusing 404s on some setups.
API IP restriction is per source IP. The most common "works in Tinker, fails in the queue" bug is that the queue worker, cron host, or container has a different egress IP than your web server. Allowlist all of them.
Credentials live in the POST body. Use TLS verification, never disable it to work around a self-signed certificate, and redact identifier/secret from request logs and exception reports.
Permissions are inherited from the admin role. A read-only API role can fetch clients but will fail AddOrder or AddInvoicePayment. Create separate credentials per integration (billing sync, support tooling, provisioning) so you can rotate and scope them independently.
Numeric fields come back as strings. Cast IDs to integers, but handle monetary amounts as decimal strings with bcmath or brick/math. Never use floats for invoice totals or payments.
Dates are WHMCS-local strings. WHMCS returns dates in its configured system timezone and format. Parse them as DateTimeImmutable in the WHMCS timezone, then convert to UTC for storage.
Response shapes are inconsistent. GetClients returns a clients list, GetClientsDetails returns nested client/stats/customfields, and some actions return ID-keyed maps rather than arrays. Normalize in your adapter and test empty results, which may be [] or an empty object.
Do not parse WHMCS error messages. They are human-readable and change between versions. Log them, but map failures to your own error codes based on the action and context.
Pagination defaults are small. Always pass limitnum, always handle totalresults, and use a conservative page size. Large pages can hit WHMCS's PHP execution limit and return a 500 or truncated response.
GetClientsDetails needs clientid or email. Calling it with neither is a common first-call mistake.
Write actions are not idempotent. AddOrder, AddClient, AddInvoicePayment, OpenTicket, and AddTicketReply can duplicate records on retry. Record intent before the call, retry only transport failures, and reconcile timeouts manually.
Adapter discovery failures look like package bugs. "No HTTP client found" or "No PSR-7 implementation" means you have not installed the adapter the package expects. Check composer.json for php-http/discovery, PSR-18, or HTTPlug, and install the matching implementation.
Set timeouts on the underlying HTTP client. WHMCS reports and order creation can be slow. Use something like a 10-second connect timeout and 60-second read timeout, then retry with backoff at the job level.
Debug with middleware, not var_dump. Add a logging middleware/plugin to the injected HTTP client to capture method, URL, sanitized body, status, and response body. Also check Utilities > Logs > API Log in WHMCS to see the action and parameters that actually arrived.
Pin the package and keep it behind an interface. This is a small, low-visibility package; pin a known-good version and avoid coupling application code directly to its classes. A thin repository layer means you can replace it with a direct Guzzle/PSR-18 call if maintenance becomes a problem.
How can I help you explore Laravel packages today?