darthsoup/laravel-whmcs
darthsoup/laravel-whmcs integrates the WHMCS API into Laravel, giving you a clean, configurable client to manage clients, orders, invoices, products, and services from your application. Includes a service provider, facade, and publishable config for quick setup and fluent API calls.
Install and publish
composer require darthsoup/laravel-whmcs
php artisan vendor:publish --tag="whmcs-config"
If the tag isn't registered in your version, fall back to --provider="DarthSoup\Whmcs\WhmcsServiceProvider". The package is auto-discovered, so no manual provider/alias registration is needed on Laravel 9+.
Configure one connection
// config/whmcs.php
'default' => 'main',
'connections' => [
'main' => [
'url' => env('WHMCS_URL'), // https://billing.example.com
'identifier' => env('WHMCS_IDENTIFIER'),
'secret' => env('WHMCS_SECRET'),
],
],
The credentials are not your admin login. In WHMCS go to System Settings → Staff Management → API Credentials and create a credential pair, then grant it an API role under Admin Roles with the specific actions you need enabled (e.g. GetClients, GetClientsDetails, AddClient).
First use case
The quickest win is a read-only call from a console command or a dashboard widget:
use DarthSoup\Whmcs\Facades\Whmcs;
$clients = Whmcs::getClients(['limitnum' => 25, 'status' => 'Active']);
The facade forwards to the underlying HTTP client, so the method name matches the WHMCS API action (GetClients → getClients()) and the array argument is the action's parameters, camel-cased the same way the API docs write them. Where to look first: config/whmcs.php for connection shape, the facade class for the call surface, and the package's tests/ directory — the fixtures there are the fastest reference for exact method names and response shapes.
On 1.4.0 specifically: this release only widens the framework constraint for Laravel 13.x and repairs the CI workflow. There are no migrations, config key changes, or API-surface changes, so composer update darthsoup/laravel-whmcs is safe mid-project. If you maintain a fork or have a lockfile pinned to the package's illuminate/* constraints, bump it now — otherwise composer require laravel/framework:^13.0 will conflict.
Wrap actions in your own service, not inline facade calls. The facade is a thin proxy with no typing, so spread Whmcs:: calls across controllers and you lose IDE support and testability. One class per domain area is the sweet spot:
final class BillingClient
{
public function find(int $clientId): ClientData
{
$response = Whmcs::getClientsDetails(['clientid' => $clientId]);
return ClientData::from($response);
}
}
Bind it to an interface in a service provider and swap it for a fake in tests. spatie/laravel-data or a hand-rolled from(array) hydrator both work; the raw response is an associative array, not an object.
Multiple connections for multi-brand / staging. Because whmcs.php supports a connections map, you can keep main and legacy side by side and switch at call time:
Whmcs::connection('legacy')->getClients();
This is also the cleanest way to point tests at a sandbox WHMCS install.
Push writes onto the queue. WHMCS API calls are slow (often 300–1500 ms) and rate-limited per API role. Anything that mutates — addClient, updateClient, addOrder, createInvoice — belongs in a job with retries and backoff:
CreateWhmcsClient::dispatch($payload)->onQueue('whmcs');
Set $tries = 3 and a backoff() of 30–60 s so a transient 429 doesn't drop the write. Keep jobs idempotent: WHMCS will happily create duplicate clients if you retry blindly, so check for an existing client by email first.
Cache reads, never mutations. Reference data (products, payment methods, currency, server groups) changes rarely and is read constantly. Cache::remember("whmcs.products.{$connection}", 3600, fn () => Whmcs::getProducts()) removes a large chunk of latency from checkout flows.
Inbound webhooks are the other half of the integration. WHMCS can POST to a Laravel route on order/invoice/ticket events. Accept the payload, verify a shared secret or IP allowlist, persist the event, and reconcile by re-fetching the record via the API rather than trusting the body. This keeps the webhook handler fast and your local state authoritative-on-read.
Fake it in tests. Either Whmcs::shouldReceive('getClients')->andReturn([...]) (facade mock, fastest) or point the main connection at a local HTTP fake and let the real client run. The second catches parameter-shape bugs the mock hides.
HTTP 200 does not mean success. WHMCS returns a JSON body with result => "success" | "error", and error payloads still come back with a 200 status. Always check result (or let the package's exception path do it) before assuming you got data — otherwise $response['clients'] blows up on an Undefined index at 3 a.m.
"Permission denied" is a role problem, not a code problem. The API credential's admin role must have each action explicitly enabled. Adding a new call to your code commonly means editing the role in WHMCS admin afterwards. Symptom: every other method works, one returns a permission error.
IP allowlist. WHMCS API credentials can be restricted to specific IPs. A deploy to a new server or a change of egress IP produces timeouts/403s that look like config errors. Check the credential's allowed IPs before debugging the package.
Self-signed certificates. Internal WHMCS installs frequently use self-signed TLS, and Guzzle will refuse. Disable verification only for the affected connection and only outside production — a global verify => false is an easy habit to leave in.
Pagination surprises. Many WHMCS list actions default to limitnum=25 regardless of what you expect. Loop with limitstart until you get fewer rows than the page size, and don't build a "sync all clients" job without that loop — it will silently truncate.
Rate limits are per role. WHMCS enforces an hourly request cap on the API role (configurable, commonly 1000). Bulk imports that run fine in dev will 429 in production. Throttle the queue worker or chunk with ->delay() between batches.
Dates are strings. Timestamps come back in WHMCS's configured format with no timezone information. Run them through Carbon::createFromFormat() with an explicit format from your WHMCS locale settings, not Carbon::parse(), or you'll get silently wrong offsets across DST boundaries.
Config caching bites in production. php artisan config:cache freezes config/whmcs.php; new env vars need a re-cache. Also namespace your env keys per connection (WHMCS_LEGACY_URL) rather than reusing one set — multi-connection setups otherwise quietly talk to the same install.
Extension points. The manager resolves connections from config, so overriding a connection at runtime (config(['whmcs.connections.main.url' => ...]) before the first call) is the pragmatic way to support per-tenant WHMCS instances without forking. If you need deeper control — custom Guzzle handler stack, request logging, tracing — bind your own client instance into the container rather than patching vendor code.
Upgrade note for 1.4.0. No action required beyond composer update. The release's only functional change is Laravel 13 constraint support; if your app is still on Laravel 11/12, staying on 1.3.0 is equally fine — nothing in 1.4.0 is worth forcing a framework upgrade for.
How can I help you explore Laravel packages today?