itxshakil / cpanel-whm
A typed, token-only, fakeable cPanel WHM API 1 client for Laravel, with UAPI through WHM, a whm:doctor connection check and Artisan commands for everyday account work.
Fund package maintenance!
Requires
- php: ^8.3
- ext-openssl: *
- guzzlehttp/guzzle: ^7.8.2|^8.0
- laravel/framework: ^12.69|^13.30
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^11.5|^12.0
- spatie/laravel-health: ^1.34
- symfony/yaml: ^7.0|^8.0
Suggests
- phpunit/phpunit: Whm::fake()'s assertions report through PHPUnit when it is installed (^11.5|^12.0).
- spatie/laravel-health: Adds WhmCheck, a health check for every WHM connection (^1.34).
Provides
None
Conflicts
None
Replaces
None
README
Create, suspend and manage cPanel accounts from Laravel through the WHM API: typed methods for almost every documented WHM API 1 and UAPI function, typed results for the everyday ones, clear errors, a fake for your tests, and a whm:doctor command that tells you exactly why a connection doesn't work.
use Itxshakil\CpanelWhm\Data\NewAccount; use Itxshakil\CpanelWhm\Facades\Whm; $account = Whm::accounts()->create(new NewAccount( username: 'acme', domain: 'acme.example', package: 'starter', )); Whm::suspensions()->suspend('acme', 'Unpaid invoice #1042'); return redirect()->away(Whm::sessions()->create('acme')->url); // one-click cPanel login
It's for hosting resellers, agencies and anyone building their own hosting billing or provisioning on Laravel.
What's covered
| Hand-written modules, typed results | accounts, suspensions, packages, quotas, login sessions, server, DNS, domains, disk and bandwidth usage, backups and restores, resellers, SSL, API tokens, transfers (77 WHM functions) |
| Generated, typed methods | 621 of 630 WHM API 1 functions with Whm::api(), 680 of 693 UAPI functions with Whm::asUser($user)->api() (coverage) |
| Anything else | Whm::call('function', [...]) and Whm::asUser($user)->uapi('Module', 'function') |
Why this package
Most WHM clients for PHP were written for Laravel 5, log in with a password or the access hash that cPanel deprecated in version 64, return raw arrays, and can't be faked in tests. This one is built from a production hosting platform's client and fixes the things that bit it:
| Problem | What this package does |
|---|---|
| WHM answers HTTP 200 even when a function fails | metadata.result = 0 throws WhmCommandFailed with WHM's reason. |
Some failures put the reason in data, leaving metadata empty |
The reason is found either way. You never get an alert that just says "OK". |
uapi_cpanel succeeds while the UAPI function inside it fails |
That raises UapiCallFailed with the UAPI errors. |
| A token without the right ACL fails with a vague message | You get WhmPermissionDenied, and whm:doctor lists the missing privileges. |
| WHM renames an account on a username collision | create() returns the username WHM actually assigned. |
| Passwords leak into URLs, logs and cURL errors | Secrets go in POST bodies; logs, events and errors are redacted. |
| Testing means mocking HTTP by hand | Use Whm::fake(), which goes through the real parsing and error paths, or replay real responses saved with whm:record. |
| Two people edit a DNS zone at once | DNS edits carry the zone's serial, so a stale edit fails instead of overwriting. |
| A network blip fails a page | Read-only calls are retried; changes never are, so nothing is created twice. |
Requirements
- PHP 8.3 or newer
- Laravel 12.69+ or 13.30+
- cPanel & WHM with an API token (WHM › Development › Manage API Tokens)
The typed API is generated from cPanel & WHM 11.138's API specification, and that is the version the package is developed against. whm:doctor warns below 11.110 (doctor.minimum_version), the oldest version we expect to work. Older servers may answer for most functions, but they aren't tested. A function your server doesn't have yet fails with WHM's own error, and php artisan whm:functions --server --missing lists the differences.
Installation
composer require itxshakil/cpanel-whm php artisan whm:install
whm:install asks for the host, user and token, writes them to .env, publishes config/cpanel-whm.php and runs whm:doctor. To configure by hand instead:
WHM_HOST=server.example.com # https and port 2087 are assumed WHM_USER=root # or a reseller WHM_TOKEN=your-api-token WHM_VERIFY_TLS=true # false only for a server reached by IP with a self-signed certificate
Check the connection
php artisan whm:doctor
WHM connection · main · https://server.example.com:2087
✔ Config root @ https://server.example.com:2087, TLS verification on
✔ DNS server.example.com → 203.0.113.10
✔ Network port 2087 open (38 ms)
✔ TLS certificate trusted, valid until 2027-01-14
✖ Auth WHM returned HTTP 401 for version.
→ The token was rejected: it may be mistyped, expired or revoked, or belong to another user. ...
· Privileges skipped: an earlier check failed
The checks run in order: config, DNS, the port, TLS, the token, its privileges, the functions your app needs, the server version and latency. The first failure stops the run and says what to fix. Use --json in CI, --all for every connection, or call Whm::ping() from your own health page.
Usage
Accounts
use Itxshakil\CpanelWhm\Enums\SearchType; use Itxshakil\CpanelWhm\Support\Filter; $created = Whm::accounts()->create(new NewAccount('acme', 'acme.example', package: 'starter')); $created->username; // what WHM assigned $created->nameservers; // ['ns1.example.com', 'ns2.example.com'] $created->password; // the one you gave, or a strong generated one Whm::accounts()->list(); // Collection<Account> Whm::accounts()->list(Filter::where('domain', 'contains', 'acme')); Whm::accounts()->search('starter', SearchType::Package, exact: true); Whm::accounts()->find('acme'); // ?Account Whm::accounts()->isUsernameAvailable('acme'); // local rules, then WHM Whm::accounts()->changePackage('acme', 'pro'); Whm::accounts()->changePassword('acme', $password); Whm::accounts()->modify('acme', ['CONTACTEMAIL' => 'new@acme.example']); Whm::accounts()->remove('acme');
Usernames are checked against cPanel's rules before any request: lowercase letters and digits, no leading digit, not starting with "test" or ending in "assword", at most 16 characters, and not one of cPanel's reserved names. A bad name throws InvalidUsername with the rule it broke.
Suspensions, packages, quotas
Whm::suspensions()->suspend('acme', 'Unpaid', lock: true); // lock: the reseller cannot unsuspend Whm::suspensions()->unsuspend('acme'); Whm::suspensions()->list(); // Collection<SuspendedAccount> Whm::packages()->list(); // Collection<Package> Whm::packages()->find('starter')?->diskQuota; Whm::quotas()->setDiskQuota('acme', 10_240); // MB, null = unlimited Whm::quotas()->setBandwidthLimit('acme', null);
Login links
use Itxshakil\CpanelWhm\Enums\SessionService; $session = Whm::sessions()->create('acme', SessionService::Cpanel, app: 'Email_Accounts'); $session->url; // one use; the session ends after 15 minutes idle $session->expiresAt;
cPanel (UAPI) functions as an account
UAPI calls run through WHM's uapi_cpanel with your WHM token, so you don't need a cPanel token per account.
$mailboxes = Whm::asUser('acme')->uapi('Email', 'list_pops'); $mailboxes->toArray(); $mailboxes->warnings; Whm::asUser('acme')->uapi('Email', 'add_pop', ['email' => 'info', 'password' => $password]);
Mailboxes and MySQL have typed helpers:
$mailbox = Whm::asUser('acme')->email()->create('info@acme.example', quotaMegabytes: 1024); $mailbox->password; // generated when you pass none Whm::asUser('acme')->mysql()->createDatabase('acme_shop'); Whm::asUser('acme')->mysql()->grant('acme_app', 'acme_shop'); Whm::asUser('acme')->email()->list(page: 2, perPage: 50); // UAPI pagination; also mysql()->databases() and users()
DNS, domains, usage, backups, resellers, SSL, tokens
use Itxshakil\CpanelWhm\Data\DnsRecord; Whm::dns()->add('acme.example', DnsRecord::a('shop', '203.0.113.20'), DnsRecord::txt('@', 'v=spf1 -all')); $www = Whm::dns()->zone('acme.example')->first('www', 'A'); // null when there's no such record if ($www !== null) { Whm::dns()->update('acme.example', $www->withData('203.0.113.30')); } Whm::domains()->createSubdomain('blog.acme.example', 'public_html/blog'); Whm::domains()->owner('acme.example'); // "acme" Whm::usage()->account('acme')->disk?->percentUsed(); // 72.4 Whm::backups()->restore('acme', Whm::backups()->dates()->last()); Whm::resellers()->create('res1'); Whm::resellers()->setPrivileges('res1', ['create-acct', 'suspend-acct', 'list-accts']); Whm::ssl()->autoSslProblems('acme'); Whm::tokens()->expiringWithin(14);
See usage for every module.
Every other function, typed
Generated from cPanel's OpenAPI documents, with named arguments, docblocks and links to cPanel's docs:
Whm::api()->ipAddressManagement()->listips(); Whm::api()->sslCertificates()->fetchSslVhosts(); Whm::asUser('acme')->api()->email()->addPop(email: 'info', password: $password, quota: 1024); Whm::asUser('acme')->api()->mysql()->createDatabase(name: 'acme_shop');
php artisan whm:functions zone finds the method for any function. See the generated API.
Any function by name
Every WHM API 1 function is also one call away, with the same error handling:
use Itxshakil\CpanelWhm\Enums\HttpMethod; $response = Whm::call('listips'); $response->get('ip.0.ip'); // dot notation into data $response->warnings(); // warnings WHM attached to a "successful" call Whm::call('modifyacct', $params, HttpMethod::Post);
GET or POST
Every call picks its HTTP method the same way, typed or by name:
- A call carrying a secret (a password, token, passphrase or key) is always POST, so the secret never lands in a URL, an access log or a proxy log.
- Otherwise a method you asked for wins:
Whm::call($function, $params, HttpMethod::Post), orWhm::withHttpMethod(...)for every call made through that client. - Otherwise functions cPanel marks read-only (
x-readonlyin its spec) are GET, and everything else is POST, including functions the spec doesn't list, such as plugins.
Whm::withHttpMethod(HttpMethod::Post)->accounts()->list(); // e.g. a WAF that only allows POST to /json-api
Retries and caching follow whether the function is read-only, not the method, so forcing a method never makes a change retried.
php artisan whm:functions --server --missing shows what your server offers beyond the spec (plugin functions, for example).
Several calls in one request
$results = Whm::batch()->add('accountsummary', ['user' => 'acme'])->add('version')->send(); $results[0]->get('acct.0.domain');
Several servers
// config/cpanel-whm.php → 'connections' => ['main' => [...], 'ca-1' => [...]] Whm::connection('ca-1')->accounts()->list();
Inject Itxshakil\CpanelWhm\Contracts\WhmClient to get the default connection.
Errors
Everything extends Itxshakil\CpanelWhm\Exceptions\WhmException, and each has a hint() with what to do next.
| Exception | When |
|---|---|
WhmConnectionFailed |
DNS, refused connection, TLS failure or timeout. mayHaveReachedServer() says whether WHM may have acted before it stopped answering. |
WhmAuthenticationFailed |
HTTP 401/403: the token was rejected. |
WhmHttpError |
Another HTTP error, or an answer that isn't JSON (wrong port or a proxy). |
WhmCommandFailed |
WHM ran the function and reported failure. Has reason(), function(), response(), rawOutput(). |
WhmPermissionDenied |
Like WhmCommandFailed, but the token's ACL doesn't allow the function. |
AccountNotFound |
Like WhmCommandFailed, but the named cPanel account doesn't exist. |
UapiCallFailed |
The UAPI function inside uapi_cpanel (or cpanel) failed. Has errors(). |
InvalidUsername |
A username broke a cPanel rule (checked before sending). |
InvalidConfiguration |
Missing host or token, unknown connection, or a cPanel port. |
Testing your app
use Itxshakil\CpanelWhm\Facades\Whm; public function test_an_overdue_invoice_suspends_the_account(): void { $whm = Whm::fake([ 'suspendacct' => Whm::response(), 'accountsummary' => Whm::failure('Account does not exist.'), ]); // ... run your code ... $whm->assertCalled('suspendacct', fn (array $params) => $params['user'] === 'acme'); $whm->assertNotCalled('removeacct'); }
Unfaked calls throw StrayWhmCall, so a test can never reach a real server. There are also Whm::sequence(), Whm::connectionError(), Whm::httpError(401), Whm::uapi([...]), Whm::uapiFailure('...') and Whm::fixture('tests/Fixtures/whm/listaccts.json') (saved with php artisan whm:record), closures, and assertCalledTimes / assertSentCount / assertNothingSent. See docs/testing.md.
Artisan commands
| Command | What it does |
|---|---|
whm:install |
Set up the connection interactively, then run the doctor |
whm:doctor (whm:test) |
Check a connection step by step (--connection, --all, --json) |
whm:call {function} {key=value…} |
Run any function (--json, --dry-run, --post); secrets always go as POST, destructive functions ask first |
whm:functions {search?} |
Find the method for any function (--uapi, --curated, --server --missing) |
whm:accounts |
List accounts (--search, --by, --package, --suspended) |
whm:account {user} |
One account's summary |
whm:login {user} |
Print a one-time login link (--service, --app) |
whm:suspend {user} / whm:unsuspend {user} |
Suspend (--reason, --lock) or unsuspend an account |
whm:packages |
Packages and their limits (--json) |
whm:dns {domain?} |
Zones, or one zone's records with line numbers (--type) |
whm:token |
API tokens and their expiry; fails when one expires within --days |
whm:usage |
Accounts near their disk or bandwidth limit; fails when one is at or above --threshold |
whm:record {function} {key=value…} |
Save a redacted real response as a test fixture |
php artisan about also shows a cPanel WHM section.
Events, retries and caching
The modules dispatch AccountCreated, AccountRemoved, AccountSuspended, AccountUnsuspended, AccountPackageChanged, AccountModified, AccountPasswordChanged and DnsZoneChanged once WHM confirms the change: a ready-made feed for an audit log. WhmFunctionSucceeded fires for every successful change, whether it came from a module, Whm::api(), Whm::call(), uapi() or a batch command.
Read-only calls are retried after a connection error or HTTP 5xx, and Whm::cache(300)->packages()->list() caches them. Calls that change something are never retried or cached.
Retries
Retries are on by default: a read-only call that fails with a connection error or an HTTP 5xx is tried twice more, 250 ms and then 500 ms later. A function that changes something (createacct, suspendacct, a batch, …) is sent once, whatever happens. That's why nothing is ever created twice.
That means a read against a server that's down can wait up to three times WHM_TIMEOUT. Turn retries down where that matters:
Whm::withoutRetries()->server()->version(); // one attempt, e.g. in a health check
WHM_RETRY_TIMES=0 # no retries for the connection WHM_RETRY_SLEEP_MS=250 # base back-off; attempt n waits n × this
If your queued jobs already retry, consider WHM_RETRY_TIMES=0 so a failing read isn't retried at two levels. WhmCheck always makes a single attempt.
Set WHM_LOG_CHANNEL=stack to log every call: function, connection, duration and outcome. Parameters are redacted (passwords, tokens and login URLs never reach the log). Add WHM_LOG_RESPONSES=true to log WHM's redacted answers too, and set log_levels to choose the level per outcome. WhmRequestSending, WhmResponseReceived and WhmRequestFailed fire for every call, with secrets already masked.
With spatie/laravel-health, add WhmCheck::new()->failWhenTokenExpiresWithin(7) to your checks.
Stability and versioning
The package follows semantic versioning. Before 1.0, a minor release may still break the public API, and the changelog says so when it does. From 1.0, only a major release will.
Public API, covered by that promise:
- the
Whmfacade andWhmManager,WhmClient,WhmResponse,WhmRequest,WhmBatch,BatchResults; - the modules (
Whm::accounts(),Whm::dns(), ... andWhm::asUser()),Data\*,Enums\*,Events\*andExceptions\*, including the exception hierarchy; Testing\WhmFakeand theWhm::fake()helpers;Support\ConnectionConfigandSupport\Filter;- config keys, environment variables, and Artisan command names and options.
Contracts. Type-hint Contracts\WhmClient and Contracts\Transport freely. Implementing them yourself (a decorator, a hand-written double) isn't covered: a minor release may add methods to them. Use Whm::fake() for tests.
The generated API (Whm::api(), asUser()->api()) follows cPanel's spec:
- Call generated methods with named arguments. Required parameters come first, but the order of the rest isn't covered and can change when the spec does.
- A minor release may add methods and optional parameters from a newer spec.
- A method or parameter cPanel removes is marked
@deprecatedfirst, and removed only in a major release.
Internal: anything marked @internal, everything else under Support\ and Transport\, Support\FunctionCatalog's array shape, and the generator in generator/ (which isn't shipped). These can change in any release.
Documentation
- Configuration
- The modules: accounts, DNS, domains, usage, backups, resellers, SSL, tokens, ...
- The generated API for every other function
- UAPI through WHM
- whm:doctor and the other commands
- Errors
- Testing
- API coverage
- Roadmap
Security
Only API tokens are supported. The token is sent in the Authorization header and nowhere else. Report vulnerabilities privately; see SECURITY.md.
License
MIT. See LICENSE.md.